yumoqing 5ba33b9e18 fix: urlwidget subwidgets replace entire page instead of rendering in-place
Bug: index.ui has VBox with 3 urlwidget subwidgets (top.ui, center.ui, bottom.ui).
When RenderUrlWidget dispatched through ActionDispatcher, loadWidget called
onWidgetLoaded which replaced the ENTIRE root widget. The last urlwidget to
finish loading (bottom.ui) overwrote top.ui and center.ui.

Fix: RenderUrlWidget now maintains local state (loadedWidget) and renders
loaded content in-place. Added ActionDispatcher.loadWidgetInPlace() that loads
via callbacks without calling onWidgetLoaded. Each urlwidget replaces only
itself, preserving sibling widgets in the parent container.
2026-05-21 14:27:34 +08:00

337 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.bricks.mp.actions
import com.bricks.mp.core.*
import com.bricks.mp.dev.DevLogStore
import com.bricks.mp.dev.DevLogLevel
import com.bricks.mp.dev.DevLogSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
* 支持回调机制以更新 UI 状态。
*
* HTTP 错误处理保持通用:
* - 403加载 /rbac/user/login.ui 并通过 onDialog 弹出;
* - 401通过 onMessage 显示服务端错误内容;
* - 3xx读取 Location 并按页面跳转加载目标 UI。
*/
class ActionDispatcher(
private val context: BricksContext,
private val http: BricksHttp,
private val scope: CoroutineScope
) {
private val registeredFunctions = mutableMapOf<String, () -> Unit>()
// 回调: 当加载新的 widget 时调用
var onWidgetLoaded: ((BricksWidget) -> Unit)? = null
// 回调: 显示消息/错误
var onMessage: ((title: String, message: String, isError: Boolean) -> Unit)? = null
// 回调: 弹出/关闭对话框
var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null
/**
* 403 时默认加载的登录 UI。应用可以改成自己的登录页路径。
*/
var loginUiPath: String = "/rbac/user/login.ui"
/**
* 注册回调函数 (registerfunction)
*/
fun registerFunction(name: String, func: () -> Unit) {
registeredFunctions[name] = func
}
/**
* 分发事件
*/
fun dispatch(bind: BricksBind) {
DevLogStore.log(
level = DevLogLevel.INFO,
message = "Dispatch: actiontype=${bind.actiontype} event=${bind.event} target=${bind.target}",
source = DevLogSource.ACTION
)
try {
when (bind.actiontype) {
"urlwidget" -> handleUrlWidget(bind)
"method" -> handleMethod(bind)
"script" -> handleScript(bind)
"registerfunction" -> handleRegisterFunction(bind)
"event" -> handleEvent(bind)
else -> {
DevLogStore.log(
level = DevLogLevel.WARN,
message = "Unknown actiontype: ${bind.actiontype}",
source = DevLogSource.ACTION
)
println("[Bricks] Unknown actiontype: ${bind.actiontype}")
}
}
} catch (e: Exception) {
DevLogStore.log(
level = DevLogLevel.ERROR,
message = "Action dispatch failed: ${bind.actiontype}",
details = "event=${bind.event}, target=${bind.target}",
source = DevLogSource.ACTION,
stackTrace = e.stackTraceToString()
)
}
}
/**
* Load a widget and return it via callbacks — does NOT call onWidgetLoaded.
* Used by RenderUrlWidget for in-place loading of subwidgets.
* Reuses the same HTTP client and handles 403/3xx redirects.
*/
suspend fun loadWidgetInPlace(
url: String,
onSuccess: (BricksWidget) -> Unit,
onError: (String) -> Unit
) {
val fullUrl = resolveUrl(url)
try {
val widget = http.fetchUi(fullUrl, authToken = context.authToken)
onSuccess(widget)
DevLogStore.log(
level = DevLogLevel.INFO,
message = "In-place load OK: $fullUrl",
source = DevLogSource.ACTION
)
} catch (e: BricksHttpException) {
handleHttpErrorInPlace(e, fullUrl, onSuccess, onError)
} catch (e: Exception) {
onError("Failed to load: ${e.message}")
DevLogStore.log(
level = DevLogLevel.ERROR,
message = "In-place load failed: $fullUrl",
details = e.message,
source = DevLogSource.ACTION,
stackTrace = e.stackTraceToString()
)
}
}
private suspend fun handleHttpErrorInPlace(
error: BricksHttpException,
requestUrl: String,
onSuccess: (BricksWidget) -> Unit,
onError: (String) -> Unit,
redirectDepth: Int = 0
) {
when (error.statusCode) {
403 -> {
try {
val loginWidget = http.fetchUi(resolveUrl(loginUiPath), authToken = context.authToken)
onDialog?.invoke(loginWidget, true)
} catch (loginError: Exception) {
onError("Forbidden: ${error.displayBody()}")
}
}
401 -> onError("Unauthorized: ${error.displayBody()}")
in 300..399 -> {
val location = error.location
if (location.isNullOrBlank()) {
onError("HTTP ${error.statusCode} without Location")
return
}
if (redirectDepth >= MAX_REDIRECTS) {
onError("Too many redirects: $location")
return
}
val nextUrl = resolveRedirectUrl(requestUrl, location)
DevLogStore.log(
level = DevLogLevel.INFO,
message = "Redirect ${error.statusCode}: $requestUrl -> $nextUrl",
source = DevLogSource.ACTION
)
loadWidgetInPlace(nextUrl, onSuccess, onError)
}
else -> onError("HTTP ${error.statusCode}: ${error.displayBody()}")
}
}
private fun handleUrlWidget(bind: BricksBind) {
val url = bind.options["url"]?.let {
if (it is JsonPrimitive) it.contentOrNull else null
} ?: bind.url ?: return
DevLogStore.log(
level = DevLogLevel.INFO,
message = "Loading URL widget: $url",
source = DevLogSource.ACTION
)
scope.launch {
try {
loadWidget(url, showAsDialog = false)
} catch (e: Exception) {
DevLogStore.log(
level = DevLogLevel.ERROR,
message = "Failed to load URL widget: $url",
details = e.message,
source = DevLogSource.ACTION,
stackTrace = e.stackTraceToString()
)
}
}
}
private suspend fun loadWidget(url: String, showAsDialog: Boolean, redirectDepth: Int = 0) {
val fullUrl = resolveUrl(url)
try {
val widget = http.fetchUi(fullUrl, authToken = context.authToken)
if (showAsDialog) {
onDialog?.invoke(widget, true)
} else {
onWidgetLoaded?.invoke(widget)
}
println("[Bricks] urlwidget loaded: $fullUrl")
} catch (e: BricksHttpException) {
handleHttpException(e, fullUrl, showAsDialog, redirectDepth)
} catch (e: Exception) {
println("[Bricks] urlwidget error: ${e.message}")
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
}
}
private suspend fun handleHttpException(
error: BricksHttpException,
requestUrl: String,
showAsDialog: Boolean,
redirectDepth: Int
) {
when (error.statusCode) {
403 -> showLoginDialog(error)
401 -> onMessage?.invoke("Unauthorized", error.displayBody(), true)
in 300..399 -> {
val location = error.location
if (location.isNullOrBlank()) {
onMessage?.invoke("Redirect", "HTTP ${error.statusCode} without Location", true)
return
}
if (redirectDepth >= MAX_REDIRECTS) {
onMessage?.invoke("Redirect", "Too many redirects: $location", true)
return
}
val nextUrl = resolveRedirectUrl(requestUrl, location)
println("[Bricks] redirect ${error.statusCode}: $requestUrl -> $nextUrl")
loadWidget(nextUrl, showAsDialog, redirectDepth + 1)
}
else -> onMessage?.invoke("HTTP ${error.statusCode}", error.displayBody(), true)
}
}
private suspend fun showLoginDialog(error: BricksHttpException) {
try {
val loginWidget = http.fetchUi(resolveUrl(loginUiPath), authToken = context.authToken)
onDialog?.invoke(loginWidget, true)
println("[Bricks] HTTP 403: login dialog loaded from $loginUiPath")
} catch (loginError: Exception) {
val message = "${error.displayBody()}\nLogin UI load failed: ${loginError.message}"
onMessage?.invoke("Forbidden", message, true)
}
}
private fun BricksHttpException.displayBody(): String =
responseBody.take(1000).ifBlank { message ?: "HTTP $statusCode" }
private fun resolveUrl(url: String): String {
if (url.startsWith("http://") || url.startsWith("https://")) return url
return context.entireUrl(url)
}
private fun resolveRedirectUrl(requestUrl: String, location: String): String {
if (location.startsWith("http://") || location.startsWith("https://")) return location
val origin = requestUrl.originPart()
return if (location.startsWith("/")) {
origin + location
} else {
val parentPath = requestUrl.pathPart().substringBeforeLast('/', missingDelimiterValue = "")
"$origin$parentPath/$location"
}
}
private fun String.originPart(): String {
val schemeEnd = indexOf("://")
if (schemeEnd < 0) return ""
val authorityStart = schemeEnd + 3
val authorityEnd = indexOf('/', startIndex = authorityStart).let { if (it < 0) length else it }
return substring(0, authorityEnd)
}
private fun String.pathPart(): String {
val schemeEnd = indexOf("://")
val pathStart = if (schemeEnd >= 0) {
indexOf('/', startIndex = schemeEnd + 3).let { if (it < 0) return "" else it }
} else {
0
}
return substring(pathStart).substringBefore('?').substringBefore('#')
}
private fun handleMethod(bind: BricksBind) {
val methodName = bind.methodname ?: bind.target ?: return
// 调用已注册的方法
registeredFunctions[methodName]?.invoke()
println("[Bricks] method called: $methodName")
}
private fun handleScript(bind: BricksBind) {
val script = bind.script ?: return
println("[Bricks] script: $script")
// 解析常见的 script 模式
when {
script.contains("this.destroy()") -> {
onDialog?.invoke(null, false)
}
script.contains("show_windows_panel") -> {
println("[Bricks] show_windows_panel triggered")
}
else -> {
// 执行已注册的 script handler
registeredFunctions[script]?.invoke()
}
}
}
private fun handleRegisterFunction(bind: BricksBind) {
val name = bind.target ?: return
registeredFunctions[name]?.invoke()
println("[Bricks] registerfunction invoked: $name")
}
private fun handleEvent(bind: BricksBind) {
when (bind.event) {
"submit" -> {
// Form submit - trigger urlwidget if configured
dispatch(bind.copy(actiontype = "urlwidget"))
}
"click" -> {
// Click event
val name = bind.target ?: return
registeredFunctions[name]?.invoke()
}
"dismissed" -> {
onDialog?.invoke(null, false)
}
else -> {
println("[Bricks] event: ${bind.event} -> ${bind.actiontype}")
}
}
}
fun close() {
registeredFunctions.clear()
}
private companion object {
const val MAX_REDIRECTS = 5
}
}