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.
337 lines
12 KiB
Kotlin
337 lines
12 KiB
Kotlin
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
|
||
}
|
||
}
|