yumoqing 43f416a6f0 feat: add DevMode with logging, HTTP interception, and debug panel
- 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
2026-05-19 23:15:37 +08:00

265 lines
9.3 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()
)
}
}
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
}
}