- DevLogStore: centralized log store with Flow-based observation (INFO/WARN/ERROR/EXCEPTION levels) - DevHttpInterceptor: capture request/response pairs with timing and body details - DevPanel: bottom panel with Logs/Network/Errors tabs, expandable entries, JSON formatting - Integrated into BricksHttp (all HTTP methods), BricksParser, ActionDispatcher, Main - Move Main.kt from shared/ to test/generic-client/ (library module should not have main) - Add test/generic-client/ as generic bricks-mp desktop host with DevMode toggle - Add kotlinx-datetime dependency for timestamp handling - Add materialIconsExtended for DevPanel icons
265 lines
9.3 KiB
Kotlin
265 lines
9.3 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()
|
||
)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|