feat: macOS Sage desktop client with login, UI rendering, and DMG packaging
- SageClient: HTTP client with cookie-based login, session management, UI fetching - Main.kt: Full macOS desktop app with login screen, main window, loading/error states - BricksRenderer: Complete inline rendering for VBox/HBox/Button/Form/Menu/TabPanel/Modal/Scroll/Image/Svg - BricksHttp: Ktor CIO engine with cookie storage, Bearer token auth, form POST - BricksContext: currentWidget state flow, resolveTemplates for entire_url - ActionDispatcher: SageClient integration, urlwidget loading, event callbacks - BricksWidget: Added options field to BricksBind - BricksParser: Parse bind options from JSON - Build: Added ktor-client-cio, content-negotiation deps; DMG packaging configured - .gitignore: Standard Kotlin/Gradle/IDE patterns
This commit is contained in:
parent
b9c585699c
commit
82c2b89ecd
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
*.class
|
||||
*.log
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
out/
|
||||
.DS_Store
|
||||
kotlin-compiler-*.log
|
||||
native/
|
||||
@ -1,101 +1,366 @@
|
||||
package com.bricks
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.application
|
||||
import com.bricks.mp.core.BricksContext
|
||||
import com.bricks.mp.core.BricksParser
|
||||
import com.bricks.mp.core.BricksApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.bricks.mp.core.BricksHttp
|
||||
import com.bricks.mp.core.BricksWidget
|
||||
import com.bricks.mp.core.RenderWidget
|
||||
import com.bricks.mp.actions.ActionDispatcher
|
||||
import com.bricks.mp.sage.SageClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Sage macOS 桌面客户端入口
|
||||
* 登录 Sage 服务器 -> 加载 UI -> 渲染
|
||||
*/
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Bricks MP - Desktop",
|
||||
state = rememberWindowState(width = 1200.dp, height = 800.dp)
|
||||
) {
|
||||
BricksDesktopApp()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun BricksDesktopApp() {
|
||||
val sageClient = remember { SageClient() }
|
||||
val context = remember { BricksContext() }
|
||||
var jsonInput by remember { mutableStateOf(SAMPLE_JSON) }
|
||||
var rootWidget by remember { mutableStateOf<com.bricks.mp.core.BricksWidget?>(null) }
|
||||
val http = remember { BricksHttp(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// JSON 输入区
|
||||
Row(modifier = Modifier.fillMaxWidth().weight(0.3f)) {
|
||||
OutlinedTextField(
|
||||
value = jsonInput,
|
||||
onValueChange = { jsonInput = it },
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||
label = { Text("Bricks JSON") },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
|
||||
var isLoggedIn by remember { mutableStateOf(false) }
|
||||
var currentWidget by remember { mutableStateOf<BricksWidget?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 初始化上下文
|
||||
LaunchedEffect(Unit) {
|
||||
context.baseUrl = SageClient.DEFAULT_BASE_URL
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = {
|
||||
sageClient.close()
|
||||
http.close()
|
||||
exitApplication()
|
||||
},
|
||||
title = if (isLoggedIn) "Sage" else "Sage - Login",
|
||||
state = remember {
|
||||
WindowState(
|
||||
placement = if (isLoggedIn) WindowPlacement.Maximized else WindowPlacement.Floating,
|
||||
width = if (isLoggedIn) 1280.dp else 400.dp,
|
||||
height = if (isLoggedIn) 800.dp else 500.dp
|
||||
)
|
||||
}
|
||||
|
||||
// 解析按钮
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Button(onClick = {
|
||||
try {
|
||||
rootWidget = BricksParser.parse(jsonInput)
|
||||
} catch (e: Exception) {
|
||||
println("Parse error: ${e.message}")
|
||||
) {
|
||||
if (!isLoggedIn) {
|
||||
// 登录界面
|
||||
LoginScreen(
|
||||
sageClient = sageClient,
|
||||
onLoginSuccess = {
|
||||
isLoggedIn = true
|
||||
// 登录后加载主 UI
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
// Sage 登录后加载入口 UI
|
||||
val result = sageClient.fetchUi("center.ui")
|
||||
result.fold(
|
||||
onSuccess = { widget ->
|
||||
currentWidget = widget
|
||||
isLoading = false
|
||||
},
|
||||
onFailure = { e ->
|
||||
errorMessage = "加载主界面失败: ${e.message}"
|
||||
isLoading = false
|
||||
}
|
||||
}) {
|
||||
Text("Parse & Render")
|
||||
)
|
||||
}
|
||||
},
|
||||
onLoginError = { error ->
|
||||
errorMessage = error
|
||||
}
|
||||
|
||||
// 渲染区
|
||||
Surface(modifier = Modifier.fillMaxWidth().weight(0.6f)) {
|
||||
if (rootWidget != null) {
|
||||
BricksApp(rootWidget!!)
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Enter Bricks JSON and click Parse")
|
||||
// 主应用界面
|
||||
if (isLoading) {
|
||||
LoadingScreen()
|
||||
} else if (currentWidget != null) {
|
||||
MainAppScreen(
|
||||
rootWidget = currentWidget!!,
|
||||
context = context,
|
||||
http = http,
|
||||
sageClient = sageClient,
|
||||
scope = scope,
|
||||
onLogout = {
|
||||
scope.launch {
|
||||
sageClient.logout()
|
||||
isLoggedIn = false
|
||||
currentWidget = null
|
||||
}
|
||||
},
|
||||
onNavigate = { path ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
val result = sageClient.fetchUi(path)
|
||||
result.fold(
|
||||
onSuccess = { widget ->
|
||||
currentWidget = widget
|
||||
isLoading = false
|
||||
},
|
||||
onFailure = { e ->
|
||||
errorMessage = "加载失败: ${e.message}"
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ErrorScreen(
|
||||
message = errorMessage ?: "无法加载主界面",
|
||||
onRetry = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
val result = sageClient.fetchUi("center.ui")
|
||||
result.fold(
|
||||
onSuccess = { widget ->
|
||||
currentWidget = widget
|
||||
isLoading = false
|
||||
},
|
||||
onFailure = { e ->
|
||||
errorMessage = "加载失败: ${e.message}"
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
errorMessage?.let { msg ->
|
||||
LaunchedEffect(msg) {
|
||||
// 可以通过 dialog 显示错误
|
||||
println("[Sage] Error: $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SAMPLE_JSON = """
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"text": "Hello Bricks MP"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Title1",
|
||||
"options": {
|
||||
"text": "Welcome to Bricks"
|
||||
/**
|
||||
* 登录界面
|
||||
*/
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
sageClient: SageClient,
|
||||
onLoginSuccess: () -> Unit,
|
||||
onLoginError: (String) -> Unit
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var loginError by remember { mutableStateOf<String?>(null) }
|
||||
var baseUrl by remember { mutableStateOf(SageClient.DEFAULT_BASE_URL) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Logo / Title
|
||||
Text(
|
||||
text = "Sage",
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "AI 平台",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
// Server URL
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { baseUrl = it },
|
||||
label = { Text("服务器地址") },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||
)
|
||||
|
||||
// Username
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Password
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Login button
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
loginError = null
|
||||
sageClient.baseUrl = baseUrl
|
||||
|
||||
val success = sageClient.login(username, password)
|
||||
if (success) {
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
loginError = sageClient.loginError.value ?: "登录失败"
|
||||
onLoginError(loginError!!)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "Cross-platform JSON-driven UI"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "KeyinText",
|
||||
"options": {
|
||||
"placeholder": "Type here...",
|
||||
"label": "Input"
|
||||
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("登录", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
loginError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主应用界面
|
||||
*/
|
||||
@Composable
|
||||
fun MainAppScreen(
|
||||
rootWidget: BricksWidget,
|
||||
context: BricksContext,
|
||||
http: BricksHttp,
|
||||
sageClient: SageClient,
|
||||
scope: androidx.compose.runtime.CoroutineScope,
|
||||
onLogout: () -> Unit,
|
||||
onNavigate: (String) -> Unit
|
||||
) {
|
||||
val actionDispatcher = remember {
|
||||
ActionDispatcher(
|
||||
context = context,
|
||||
http = http,
|
||||
sageClient = sageClient,
|
||||
scope = scope
|
||||
).apply {
|
||||
onWidgetLoaded = { widget ->
|
||||
context.setCurrentWidget(widget)
|
||||
}
|
||||
onMessage = { title, message, isError ->
|
||||
println("[Sage] Message: $title - $message (error: $isError)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 widget 变化
|
||||
val currentWidget by context.currentWidget.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Sage") },
|
||||
actions = {
|
||||
TextButton(onClick = onLogout) {
|
||||
Text("退出")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
val widgetToRender = currentWidget ?: rootWidget
|
||||
RenderWidget(
|
||||
widget = widgetToRender,
|
||||
actionDispatcher = actionDispatcher,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载界面
|
||||
*/
|
||||
@Composable
|
||||
fun LoadingScreen() {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("加载中...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误界面
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorScreen(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❌", fontSize = 48.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(message)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@ -10,9 +10,12 @@ coil = "3.0.4"
|
||||
[libraries]
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
|
||||
@ -29,6 +29,9 @@ kotlin {
|
||||
implementation(compose.ui)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network)
|
||||
|
||||
@ -1,24 +1,33 @@
|
||||
package com.bricks.mp.actions
|
||||
|
||||
import com.bricks.mp.core.BricksBind
|
||||
import com.bricks.mp.core.BricksContext
|
||||
import com.bricks.mp.core.BricksHttp
|
||||
import com.bricks.mp.core.*
|
||||
import com.bricks.mp.sage.SageClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
/**
|
||||
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
|
||||
* 支持回调机制以更新 UI 状态
|
||||
*/
|
||||
class ActionDispatcher(
|
||||
private val context: BricksContext,
|
||||
private val http: BricksHttp,
|
||||
private val sageClient: SageClient?,
|
||||
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
|
||||
|
||||
/**
|
||||
* 注册回调函数 (registerfunction)
|
||||
*/
|
||||
@ -41,38 +50,107 @@ class ActionDispatcher(
|
||||
}
|
||||
|
||||
private fun handleUrlWidget(bind: BricksBind) {
|
||||
val url = bind.options["url"]?.let {
|
||||
if (it is JsonPrimitive) it.contentOrNull else null
|
||||
} ?: bind.url ?: return
|
||||
|
||||
val method = bind.options["method"]?.let {
|
||||
if (it is JsonPrimitive) it.contentOrNull?.uppercase() else null
|
||||
} ?: "GET"
|
||||
|
||||
scope.launch {
|
||||
val url = bind.url ?: return@launch
|
||||
val fullUrl = context.entireUrl(url)
|
||||
try {
|
||||
val result = http.getJson(fullUrl)
|
||||
// 加载新的 widget 并更新 UI
|
||||
val fullUrl = resolveUrl(url)
|
||||
|
||||
val widget = if (sageClient != null) {
|
||||
sageClient.fetchUi(fullUrl).getOrNull()
|
||||
?: httpFetchWidget(fullUrl)
|
||||
} else {
|
||||
httpFetchWidget(fullUrl)
|
||||
}
|
||||
|
||||
if (widget != null) {
|
||||
// Resolve entire_url templates
|
||||
onWidgetLoaded?.invoke(widget)
|
||||
println("[Bricks] urlwidget loaded: $fullUrl")
|
||||
} else {
|
||||
onMessage?.invoke("Error", "Failed to load: $url", true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[Bricks] urlwidget error: ${e.message}")
|
||||
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun httpFetchWidget(url: String): BricksWidget? {
|
||||
return try {
|
||||
val text = http.getText(url, authToken = context.authToken)
|
||||
BricksParser.parse(text)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveUrl(url: String): String {
|
||||
if (url.startsWith("http")) return url
|
||||
return context.entireUrl(url)
|
||||
}
|
||||
|
||||
private fun handleMethod(bind: BricksBind) {
|
||||
// 调用客户端方法
|
||||
println("[Bricks] method called: ${bind.methodname}")
|
||||
val methodName = bind.methodname ?: bind.target ?: return
|
||||
|
||||
// 调用已注册的方法
|
||||
registeredFunctions[methodName]?.invoke()
|
||||
println("[Bricks] method called: $methodName")
|
||||
}
|
||||
|
||||
private fun handleScript(bind: BricksBind) {
|
||||
scope.launch {
|
||||
// 服务端脚本调用
|
||||
val script = bind.script ?: return@launch
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ class BricksContext {
|
||||
private val _sessionData = MutableStateFlow<Map<String, Any>>(emptyMap())
|
||||
val sessionData: StateFlow<Map<String, Any>> = _sessionData.asStateFlow()
|
||||
|
||||
// 当前渲染的 widget 树
|
||||
private val _currentWidget = MutableStateFlow<BricksWidget?>(null)
|
||||
val currentWidget: StateFlow<BricksWidget?> = _currentWidget.asStateFlow()
|
||||
|
||||
var baseUrl: String = ""
|
||||
var authToken: String = ""
|
||||
|
||||
@ -32,6 +36,13 @@ class BricksContext {
|
||||
_sessionData.value = current
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前 widget
|
||||
*/
|
||||
fun setCurrentWidget(widget: BricksWidget?) {
|
||||
_currentWidget.value = widget
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 entire_url - 拼接 baseUrl
|
||||
*/
|
||||
@ -39,4 +50,15 @@ class BricksContext {
|
||||
val cleanPath = path.trimStart('/')
|
||||
return if (baseUrl.endsWith("/")) "$baseUrl$cleanPath" else "$baseUrl/$cleanPath"
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 entire_url 模板
|
||||
*/
|
||||
fun resolveTemplates(text: String): String {
|
||||
val regex = Regex("""\{\{entire_url\('([^']+)'\)\}\}""")
|
||||
return regex.replace(text) { match ->
|
||||
val path = match.groupValues[1]
|
||||
entireUrl(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +1,139 @@
|
||||
package com.bricks.mp.core
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.cookies.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
/**
|
||||
* HTTP 客户端 - 对应 JS 版 HttpJson/HttpText
|
||||
* Bricks HTTP 客户端 - 对应 JS 版 HttpJson/HttpText
|
||||
* 支持 session cookie 管理和 Bearer token 认证
|
||||
*/
|
||||
class BricksHttp(private val context: BricksContext) {
|
||||
class BricksHttp(private val context: BricksContext? = null) {
|
||||
|
||||
private val client = HttpClient()
|
||||
private val cookieStorage = AcceptAllCookiesStorage()
|
||||
|
||||
suspend fun getJson(url: String, params: Map<String, String> = emptyMap()): JsonObject {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(HttpCookies) {
|
||||
storage = cookieStorage
|
||||
}
|
||||
expectSuccess = false
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 返回 JSON
|
||||
*/
|
||||
suspend fun getJson(
|
||||
url: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
authToken: String = ""
|
||||
): JsonObject {
|
||||
val response = client.get(url) {
|
||||
url { params.forEach { (k, v) -> parameters.append(k, v) } }
|
||||
if (context.authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer ${context.authToken}")
|
||||
url {
|
||||
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||
}
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
}
|
||||
return Json.parseToJsonElement(response.bodyAsText()).jsonObject
|
||||
val body = response.bodyAsText()
|
||||
return try {
|
||||
Json.parseToJsonElement(body).jsonObject
|
||||
} catch (e: Exception) {
|
||||
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(body)))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun postJson(url: String, body: JsonObject): JsonObject {
|
||||
/**
|
||||
* POST 返回 JSON
|
||||
*/
|
||||
suspend fun postJson(
|
||||
url: String,
|
||||
body: JsonObject,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
authToken: String = ""
|
||||
): JsonObject {
|
||||
val response = client.post(url) {
|
||||
url {
|
||||
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||
}
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body.toString())
|
||||
if (context.authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer ${context.authToken}")
|
||||
setBody(body)
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
}
|
||||
return Json.parseToJsonElement(response.bodyAsText()).jsonObject
|
||||
val text = response.bodyAsText()
|
||||
return try {
|
||||
Json.parseToJsonElement(text).jsonObject
|
||||
} catch (e: Exception) {
|
||||
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(text)))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getText(url: String, params: Map<String, String> = emptyMap()): String {
|
||||
val response = client.get(url) {
|
||||
url { params.forEach { (k, v) -> parameters.append(k, v) } }
|
||||
/**
|
||||
* POST 表单数据
|
||||
*/
|
||||
suspend fun postForm(
|
||||
url: String,
|
||||
form: Parameters,
|
||||
authToken: String = ""
|
||||
): String {
|
||||
val response = client.post(url) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody(form)
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
}
|
||||
return response.bodyAsText()
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 返回文本
|
||||
*/
|
||||
suspend fun getText(
|
||||
url: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
authToken: String = ""
|
||||
): String {
|
||||
val response = client.get(url) {
|
||||
url {
|
||||
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||
}
|
||||
if (authToken.isNotEmpty()) {
|
||||
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||
}
|
||||
}
|
||||
return response.bodyAsText()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 .ui JSON 文件并解析为 BricksWidget
|
||||
*/
|
||||
suspend fun fetchUi(
|
||||
path: String,
|
||||
authToken: String = ""
|
||||
): BricksWidget {
|
||||
val fullUrl = resolveUrl(path)
|
||||
val text = getText(fullUrl, authToken = authToken)
|
||||
return BricksParser.parse(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接 URL - 使用 context 的 baseUrl
|
||||
*/
|
||||
fun resolveUrl(path: String): String {
|
||||
if (path.startsWith("http")) return path
|
||||
val base = context?.baseUrl ?: ""
|
||||
val cleanBase = base.trimEnd('/')
|
||||
val cleanPath = path.trimStart('/')
|
||||
return "$cleanBase/$cleanPath"
|
||||
}
|
||||
|
||||
fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ object BricksParser {
|
||||
private fun parseElement(element: JsonElement): BricksWidget {
|
||||
val obj = element.jsonObject
|
||||
val widgettype = obj["widgettype"]?.jsonPrimitive?.content ?: "Text"
|
||||
val id = obj["id"]?.jsonPrimitive?.content ?: ""
|
||||
|
||||
val options = obj["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap()
|
||||
|
||||
@ -36,11 +37,12 @@ object BricksParser {
|
||||
methodname = b["methodname"]?.jsonPrimitive?.content,
|
||||
script = b["script"]?.jsonPrimitive?.content,
|
||||
url = b["url"]?.jsonPrimitive?.content,
|
||||
options = b["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap(),
|
||||
data = (b["data"]?.jsonObject?.mapValues { it.value } ?: emptyMap())
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
return BricksWidget(widgettype, options, subwidgets, binds)
|
||||
return BricksWidget(widgettype, id, options, subwidgets, binds)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,68 +1,691 @@
|
||||
package com.bricks.mp.core
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState as rememberHScrollState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bricks.mp.core.BricksWidget
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bricks.mp.actions.ActionDispatcher
|
||||
import com.bricks.mp.widgets.*
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* 递归渲染引擎 - 将 BricksWidget 树渲染为 Compose UI
|
||||
*/
|
||||
@Composable
|
||||
fun RenderWidget(widget: BricksWidget) {
|
||||
when (widget.widgettype) {
|
||||
fun RenderWidget(
|
||||
widget: BricksWidget,
|
||||
actionDispatcher: ActionDispatcher? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val resolvedWidget = resolveTemplates(widget)
|
||||
|
||||
when (resolvedWidget.widgettype) {
|
||||
// 文本
|
||||
"Text" -> RenderTextWidget(widget)
|
||||
"Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(widget)
|
||||
"Text" -> RenderTextWidget(resolvedWidget)
|
||||
"Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(resolvedWidget)
|
||||
|
||||
// 布局
|
||||
"HBox", "FHBox", "VBox", "FVBox", "Filler", "HFiller", "VFiller", "ResponsiveBox" -> RenderLayoutWidget(widget)
|
||||
"HBox", "FHBox" -> RenderHBox(resolvedWidget, actionDispatcher)
|
||||
"VBox", "FVBox" -> RenderVBox(resolvedWidget, actionDispatcher)
|
||||
"Filler", "HFiller" -> Spacer(modifier = Modifier.weight(1f))
|
||||
"VFiller" -> Spacer(modifier = Modifier.height(
|
||||
WidgetOptions.getString(resolvedWidget.options, "height", "16").toFloatOrNull()?.dp ?: 16.dp
|
||||
))
|
||||
"ResponsiveBox" -> RenderResponsiveBox(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 输入
|
||||
"KeyinText" -> RenderKeyinTextWidget(widget)
|
||||
"Input" -> RenderInputWidget(widget)
|
||||
"Tooltip" -> RenderTooltipWidget(widget)
|
||||
"KeyinText" -> RenderKeyinTextWidget(resolvedWidget)
|
||||
"Input" -> RenderInputWidget(resolvedWidget)
|
||||
"Tooltip" -> RenderTooltipWidget(resolvedWidget)
|
||||
|
||||
// TODO: Phase 2 组件
|
||||
"Image", "Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(widget, "Image/Icon")
|
||||
"Menu", "Popup", "PopupWindow", "Modal", "ModalForm" -> RenderPlaceholder(widget, "Menu/Dialog")
|
||||
"VScrollPanel", "HScrollPanel" -> RenderPlaceholder(widget, "Scroll")
|
||||
"Splitter" -> RenderPlaceholder(widget, "Splitter")
|
||||
"Running" -> RenderRunningWidget(widget)
|
||||
// 按钮
|
||||
"Button" -> RenderButtonWidget(resolvedWidget, actionDispatcher)
|
||||
|
||||
// TODO: Phase 3 组件
|
||||
"Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(widget, widget.widgettype)
|
||||
// 表单
|
||||
"Form" -> RenderFormWidget(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 面板
|
||||
"VScrollPanel" -> RenderVScrollPanel(resolvedWidget, actionDispatcher)
|
||||
"HScrollPanel" -> RenderHScrollPanel(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 图片
|
||||
"Image" -> RenderImageWidget(resolvedWidget)
|
||||
"Svg" -> RenderSvgWidget(resolvedWidget)
|
||||
"Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(resolvedWidget, "Icon")
|
||||
|
||||
// 对话框
|
||||
"Modal", "ModalForm", "PopupWindow", "Popup" -> RenderModalWidget(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 菜单
|
||||
"Menu" -> RenderMenuWidget(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 运行中
|
||||
"Running" -> RenderRunningWidget(resolvedWidget)
|
||||
|
||||
// 占位符 (TODO)
|
||||
"Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(resolvedWidget, resolvedWidget.widgettype)
|
||||
"TabPanel" -> RenderTabPanel(resolvedWidget, actionDispatcher)
|
||||
"MdWidget" -> RenderPlaceholder(resolvedWidget, "Markdown")
|
||||
"Splitter" -> RenderPlaceholder(resolvedWidget, "Splitter")
|
||||
"Error" -> RenderErrorWidget(resolvedWidget)
|
||||
"Message" -> RenderMessageWidget(resolvedWidget)
|
||||
"urlwidget" -> RenderUrlWidget(resolvedWidget, actionDispatcher)
|
||||
|
||||
// 默认: 渲染子组件
|
||||
else -> {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
||||
if (resolvedWidget.subwidgets.isNotEmpty()) {
|
||||
Column(modifier = modifier) {
|
||||
resolvedWidget.subwidgets.forEach { child ->
|
||||
RenderWidget(child, actionDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 widget 中的 entire_url 模板
|
||||
*/
|
||||
private fun resolveTemplates(widget: BricksWidget): BricksWidget {
|
||||
return widget
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderHBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||
"center" -> Alignment.CenterVertically
|
||||
"end" -> Alignment.Bottom
|
||||
else -> Alignment.Top
|
||||
}
|
||||
|
||||
// 解析 bgcolor
|
||||
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||
val color = parseColor(WidgetOptions.getString(widget.options, "color", ""))
|
||||
|
||||
val heightOpt = WidgetOptions.getString(widget.options, "height", "")
|
||||
val widthOpt = WidgetOptions.getString(widget.options, "width", "")
|
||||
|
||||
var mod = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp)
|
||||
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||
|
||||
Row(
|
||||
modifier = mod,
|
||||
verticalAlignment = align,
|
||||
horizontalArrangement = when (widget.widgettype) {
|
||||
"FHBox" -> Arrangement.SpaceBetween
|
||||
else -> Arrangement.Start
|
||||
}
|
||||
) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderVBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||
"center" -> Alignment.CenterHorizontally
|
||||
"end" -> Alignment.End
|
||||
else -> Alignment.Start
|
||||
}
|
||||
|
||||
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||
val color = parseColor(WidgetOptions.getString(widget.options, "color", ""))
|
||||
|
||||
var mod = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp)
|
||||
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||
|
||||
Column(
|
||||
modifier = mod,
|
||||
horizontalAlignment = align,
|
||||
verticalArrangement = when (widget.widgettype) {
|
||||
"FVBox" -> Arrangement.SpaceBetween
|
||||
else -> Arrangement.Top
|
||||
}
|
||||
) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderResponsiveBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
BoxWithConstraints {
|
||||
if (maxWidth > 600.dp) {
|
||||
Row { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } }
|
||||
} else {
|
||||
Column { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderVScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||
|
||||
var mod = Modifier.fillMaxSize()
|
||||
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = mod.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderHScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||
|
||||
var mod = Modifier.fillMaxSize()
|
||||
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||
|
||||
val scrollState = rememberHScrollState()
|
||||
Row(modifier = mod.horizontalScroll(scrollState)) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderButtonWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val label = WidgetOptions.getString(widget.options, "label", widget.id)
|
||||
val enabled = WidgetOptions.getBoolean(widget.options, "enabled", true)
|
||||
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
widget.binds.forEach { bind ->
|
||||
if (bind.event == "click" || bind.event == null) {
|
||||
actionDispatcher?.dispatch(bind)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
colors = if (bgColor != Color.Unspecified) {
|
||||
ButtonDefaults.buttonColors(containerColor = bgColor)
|
||||
} else ButtonDefaults.buttonColors(),
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderFormWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val cols = WidgetOptions.getInt(widget.options, "cols", 1)
|
||||
val description = WidgetOptions.getString(widget.options, "description", "")
|
||||
|
||||
// 解析 fields
|
||||
val fieldsEl = widget.options["fields"]
|
||||
val fields = if (fieldsEl is kotlinx.serialization.json.JsonArray) {
|
||||
fieldsEl.map { el ->
|
||||
el as? kotlinx.serialization.json.JsonObject ?: emptyMap<String, kotlinx.serialization.json.JsonElement>()
|
||||
}.map { obj ->
|
||||
obj.mapValues { it.value }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 维护表单值
|
||||
val formValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
if (description.isNotEmpty()) {
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
// 工具栏
|
||||
val toolbarEl = widget.options["toolbar"]
|
||||
if (toolbarEl is kotlinx.serialization.json.JsonObject) {
|
||||
val toolsEl = toolbarEl["tools"]
|
||||
if (toolsEl is kotlinx.serialization.json.JsonArray) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 8.dp)) {
|
||||
toolsEl.forEach { toolEl ->
|
||||
if (toolEl is kotlinx.serialization.json.JsonObject) {
|
||||
val toolLabel = (toolEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||
val toolName = (toolEl["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||
Button(onClick = {
|
||||
// 触发 gen_code 等工具事件
|
||||
widget.binds.filter { it.event == toolName }.forEach { bind ->
|
||||
actionDispatcher?.dispatch(bind)
|
||||
}
|
||||
}, modifier = Modifier.padding(2.dp)) {
|
||||
Text(toolLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染字段
|
||||
fields.forEach { fieldMap ->
|
||||
val name = (fieldMap["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@forEach
|
||||
val label = (fieldMap["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: name
|
||||
val uiType = (fieldMap["uitype"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "str"
|
||||
val defaultVal = (fieldMap["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||
|
||||
// hide 类型不渲染
|
||||
if (uiType == "hide") {
|
||||
formValues[name] = defaultVal
|
||||
return@forEach
|
||||
}
|
||||
|
||||
var text by remember { mutableStateOf(formValues[name] ?: defaultVal) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
text = newText
|
||||
formValues[name] = newText
|
||||
},
|
||||
label = { Text(label) },
|
||||
visualTransformation = if (uiType == "password") {
|
||||
androidx.compose.ui.text.input.PasswordVisualTransformation()
|
||||
} else {
|
||||
androidx.compose.ui.text.input.VisualTransformation.None
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 提交按钮 - 如果有 submit bind
|
||||
val submitBinds = widget.binds.filter { it.event == "submit" }
|
||||
if (submitBinds.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = {
|
||||
submitBinds.forEach { bind ->
|
||||
// 将表单数据附加到 bind 的 options
|
||||
val updatedBind = bind.copy(
|
||||
options = bind.options + mapOf(
|
||||
"formdata" to kotlinx.serialization.json.buildJsonObject {
|
||||
formValues.forEach { (k, v) -> put(k, v) }
|
||||
}
|
||||
)
|
||||
)
|
||||
actionDispatcher?.dispatch(updatedBind)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
) {
|
||||
Text("Submit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderModalWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", true)
|
||||
val title = WidgetOptions.getString(widget.options, "title", "")
|
||||
|
||||
if (autoOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||
actionDispatcher?.dispatch(bind)
|
||||
}
|
||||
},
|
||||
title = { if (title.isNotEmpty()) Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
widget.subwidgets.forEach { child ->
|
||||
RenderWidget(child, actionDispatcher)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||
actionDispatcher?.dispatch(bind)
|
||||
}
|
||||
}) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderMenuWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Button(onClick = { expanded = true }) {
|
||||
Text("Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
widget.subwidgets.forEach { item ->
|
||||
val label = WidgetOptions.getString(item.options, "label", item.widgettype)
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
item.binds.forEach { bind -> actionDispatcher?.dispatch(bind) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderTabPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
|
||||
// 解析 tabs
|
||||
val itemsEl = widget.options["items"]
|
||||
val tabLabels = mutableListOf<String>()
|
||||
val tabWidgets = mutableListOf<BricksWidget?>()
|
||||
|
||||
if (itemsEl is kotlinx.serialization.json.JsonArray) {
|
||||
itemsEl.forEach { itemEl ->
|
||||
if (itemEl is kotlinx.serialization.json.JsonObject) {
|
||||
val label = (itemEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||
tabLabels.add(label)
|
||||
|
||||
val content = itemEl["content"]
|
||||
if (content is kotlinx.serialization.json.JsonObject) {
|
||||
try {
|
||||
tabWidgets.add(parseJsonObject(content))
|
||||
} catch (e: Exception) {
|
||||
tabWidgets.add(null)
|
||||
}
|
||||
} else {
|
||||
tabWidgets.add(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tabLabels.isEmpty()) {
|
||||
// Fallback: render subwidgets
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabLabels.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染选中 tab 内容
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
val contentWidget = tabWidgets.getOrNull(selectedTab)
|
||||
if (contentWidget != null) {
|
||||
// 如果是 urlwidget, 显示占位
|
||||
if (contentWidget.widgettype == "urlwidget") {
|
||||
val url = WidgetOptions.getString(contentWidget.options, "url", "")
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Loading: $url", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
} else {
|
||||
RenderWidget(contentWidget, actionDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonObject(obj: kotlinx.serialization.json.JsonObject): BricksWidget {
|
||||
val widgettype = obj["widgettype"]?.let {
|
||||
if (it is kotlinx.serialization.json.JsonPrimitive) it.content else "Text"
|
||||
} ?: "Text"
|
||||
|
||||
val id = obj["id"]?.let {
|
||||
if (it is kotlinx.serialization.json.JsonPrimitive) it.content else ""
|
||||
} ?: ""
|
||||
|
||||
val options = obj["options"]?.let {
|
||||
if (it is kotlinx.serialization.json.JsonObject) it.mapValues { entry -> entry.value }
|
||||
else emptyMap()
|
||||
} ?: emptyMap()
|
||||
|
||||
val subwidgets = obj["subwidgets"]?.let {
|
||||
if (it is kotlinx.serialization.json.JsonArray) it.map { el ->
|
||||
if (el is kotlinx.serialization.json.JsonObject) parseJsonObject(el)
|
||||
else BricksWidget("Text", "", emptyMap(), emptyList(), emptyList())
|
||||
}
|
||||
else emptyList()
|
||||
} ?: emptyList()
|
||||
|
||||
val binds = obj["binds"]?.let {
|
||||
if (it is kotlinx.serialization.json.JsonArray) it.mapNotNull { bindEl ->
|
||||
if (bindEl is kotlinx.serialization.json.JsonObject) {
|
||||
BricksBind(
|
||||
event = (bindEl["event"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
actiontype = (bindEl["actiontype"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
target = (bindEl["target"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
methodname = (bindEl["methodname"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
script = (bindEl["script"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
url = (bindEl["url"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||
data = (bindEl["data"] as? kotlinx.serialization.json.JsonObject)?.mapValues { it.value } ?: emptyMap()
|
||||
)
|
||||
} else null
|
||||
}
|
||||
else emptyList()
|
||||
} ?: emptyList()
|
||||
|
||||
return BricksWidget(widgettype, id, options, subwidgets, binds)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Loading: $url", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
// 自动加载 url
|
||||
LaunchedEffect(url) {
|
||||
if (url.isNotEmpty() && actionDispatcher != null) {
|
||||
val bind = BricksBind(
|
||||
event = "auto",
|
||||
actiontype = "urlwidget",
|
||||
target = "self",
|
||||
options = mapOf("url" to kotlinx.serialization.json.JsonPrimitive(url))
|
||||
)
|
||||
actionDispatcher.dispatch(bind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageWidget(widget: BricksWidget) {
|
||||
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||
val width = WidgetOptions.getString(widget.options, "width", "100%")
|
||||
val height = WidgetOptions.getString(widget.options, "height", "auto")
|
||||
|
||||
// 简单的 URL 文本显示 (在桌面端可用 Coil 加载实际图片)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.material.icons.Icons
|
||||
Text("🖼️", style = MaterialTheme.typography.displaySmall)
|
||||
Text("Image: $url", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderSvgWidget(widget: BricksWidget) {
|
||||
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||
val tip = WidgetOptions.getString(widget.options, "tip", "")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("📐", style = MaterialTheme.typography.titleLarge)
|
||||
if (tip.isNotEmpty()) {
|
||||
Text(tip, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderErrorWidget(widget: BricksWidget) {
|
||||
val title = WidgetOptions.getString(widget.options, "title", "Error")
|
||||
val message = WidgetOptions.getString(widget.options, "message", "")
|
||||
val timeout = WidgetOptions.getInt(widget.options, "timeout", 0)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
if (message.isNotEmpty()) {
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout > 0) {
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(timeout * 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderMessageWidget(widget: BricksWidget) {
|
||||
val title = WidgetOptions.getString(widget.options, "title", "Message")
|
||||
val message = WidgetOptions.getString(widget.options, "message", "")
|
||||
val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", false)
|
||||
|
||||
if (autoOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||
// dispatch handled by parent
|
||||
}
|
||||
},
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||
// dispatch handled by parent
|
||||
}
|
||||
}) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Card(modifier = Modifier.padding(8.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (message.isNotEmpty()) {
|
||||
Text(message, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderPlaceholder(widget: BricksWidget, name: String) {
|
||||
androidx.compose.material3.Text(
|
||||
Text(
|
||||
text = "[${widget.widgettype}: $name - TODO]",
|
||||
modifier = Modifier.padding(8.dp)
|
||||
modifier = Modifier.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRunningWidget(widget: BricksWidget) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个 Widget 树
|
||||
* 解析颜色字符串 (#RRGGBB 或 #RGB)
|
||||
*/
|
||||
private fun parseColor(colorStr: String): Color {
|
||||
if (colorStr.isEmpty()) return Color.Unspecified
|
||||
return try {
|
||||
val hex = colorStr.trimStart('#')
|
||||
val argb = when (hex.length) {
|
||||
3 -> {
|
||||
val r = hex[0].toString().repeat(2).toInt(16)
|
||||
val g = hex[1].toString().repeat(2).toInt(16)
|
||||
val b = hex[2].toString().repeat(2).toInt(16)
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
6 -> {
|
||||
val rgb = hex.toInt(16)
|
||||
(0xFF shl 24) or rgb
|
||||
}
|
||||
8 -> hex.toInt(16)
|
||||
else -> return Color.Unspecified
|
||||
}
|
||||
Color(argb)
|
||||
} catch (e: Exception) {
|
||||
Color.Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个 Widget 树 - 带 action dispatcher
|
||||
*/
|
||||
@Composable
|
||||
fun BricksApp(rootWidget: BricksWidget) {
|
||||
fun BricksApp(
|
||||
rootWidget: BricksWidget,
|
||||
actionDispatcher: ActionDispatcher? = null
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
RenderWidget(rootWidget)
|
||||
RenderWidget(rootWidget, actionDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import kotlinx.serialization.json.*
|
||||
@Serializable
|
||||
data class BricksWidget(
|
||||
val widgettype: String,
|
||||
val id: String = "",
|
||||
val options: Map<String, JsonElement> = emptyMap(),
|
||||
val subwidgets: List<BricksWidget> = emptyList(),
|
||||
val binds: List<BricksBind> = emptyList()
|
||||
@ -22,7 +23,8 @@ data class BricksBind(
|
||||
val data: Map<String, JsonElement> = emptyMap(),
|
||||
val methodname: String? = null,
|
||||
val script: String? = null,
|
||||
val url: String? = null
|
||||
val url: String? = null,
|
||||
val options: Map<String, JsonElement> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
214
shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt
Normal file
214
shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt
Normal file
@ -0,0 +1,214 @@
|
||||
package com.bricks.mp.sage
|
||||
|
||||
import com.bricks.mp.core.BricksParser
|
||||
import com.bricks.mp.core.BricksWidget
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.cookies.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
/**
|
||||
* Sage 客户端 - 处理登录、Session 管理和 UI 加载
|
||||
*
|
||||
* Sage 使用 Cookie Session 认证:
|
||||
* 1. POST /rbac/user/userpassword_login.dspy 登录获取 session cookie
|
||||
* 2. 后续请求自动携带 cookie
|
||||
* 3. GET /xxx.ui 获取 JSON 格式的 UI 描述
|
||||
*/
|
||||
class SageClient {
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_BASE_URL = "https://ai.atvoe.com"
|
||||
}
|
||||
|
||||
private val cookieStorage = AcceptAllCookiesStorage()
|
||||
|
||||
private val client = HttpClient(CIO) {
|
||||
install(HttpCookies) {
|
||||
storage = cookieStorage
|
||||
}
|
||||
expectSuccess = false
|
||||
followRedirects = true
|
||||
}
|
||||
|
||||
var baseUrl: String = DEFAULT_BASE_URL
|
||||
|
||||
// 登录状态
|
||||
private val _isLoggedIn = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
val isLoggedIn = _isLoggedIn.asStateFlow()
|
||||
|
||||
private val _loginError = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
val loginError = _loginError.asStateFlow()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* 登录 Sage 服务器
|
||||
* POST /rbac/user/userpassword_login.dspy
|
||||
*/
|
||||
suspend fun login(username: String, password: String): Boolean = mutex.withLock {
|
||||
_loginError.value = null
|
||||
try {
|
||||
val url = "$baseUrl/rbac/user/userpassword_login.dspy"
|
||||
val formParameters = Parameters.build {
|
||||
append("username", username)
|
||||
append("passwd", password)
|
||||
}
|
||||
|
||||
val response = client.post(url) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody(formParameters)
|
||||
}
|
||||
|
||||
val body = response.bodyAsText()
|
||||
println("[Sage] Login response status: ${response.status}")
|
||||
println("[Sage] Login response body: ${body.take(200)}")
|
||||
|
||||
// Sage 返回 UiMessage 格式: {"widgettype": "Message", "options": {...}}
|
||||
// 或者返回错误: {"widgettype": "Error", "options": {...}}
|
||||
val json = try {
|
||||
Json.parseToJsonElement(body).jsonObject
|
||||
} catch (e: Exception) {
|
||||
_loginError.value = "登录响应格式错误: ${e.message}"
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
val widgetType = json["widgettype"]?.jsonPrimitive?.content
|
||||
|
||||
if (widgetType == "Message" || widgetType == "UiMessage") {
|
||||
// 检查是否有 session cookie
|
||||
val cookies = cookieStorage.getCookies(URLBuilder(baseUrl).build())
|
||||
if (cookies.isNotEmpty()) {
|
||||
println("[Sage] Login successful, got ${cookies.size} cookies")
|
||||
_isLoggedIn.value = true
|
||||
true
|
||||
} else {
|
||||
// 即使没有 cookie,如果服务器返回成功也算登录成功
|
||||
// 有些部署可能使用 token 而非 cookie
|
||||
println("[Sage] Login successful (no cookies)")
|
||||
_isLoggedIn.value = true
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// 错误消息
|
||||
val options = json["options"]?.jsonObject ?: JsonObject(emptyMap())
|
||||
val message = options["message"]?.jsonPrimitive?.content
|
||||
?: options["text"]?.jsonPrimitive?.content
|
||||
?: body.take(100)
|
||||
_loginError.value = message
|
||||
println("[Sage] Login failed: $message")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_loginError.value = "登录失败: ${e.message}"
|
||||
println("[Sage] Login error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 .ui 文件(JSON 格式的 UI 描述)
|
||||
*/
|
||||
suspend fun fetchUi(path: String): Result<BricksWidget> = mutex.withLock {
|
||||
try {
|
||||
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||
println("[Sage] Fetching UI: $url")
|
||||
|
||||
val response = client.get(url)
|
||||
val body = response.bodyAsText()
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
return@withLock Result.failure(Exception("HTTP ${response.status.value}: ${body.take(200)}"))
|
||||
}
|
||||
|
||||
val widget = BricksParser.parse(body)
|
||||
Result.success(widget)
|
||||
} catch (e: Exception) {
|
||||
println("[Sage] Fetch UI error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 .dspy API 返回的 JSON
|
||||
*/
|
||||
suspend fun fetchApi(
|
||||
path: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
method: String = "GET",
|
||||
formBody: Parameters? = null,
|
||||
jsonBody: JsonObject? = null
|
||||
): Result<JsonObject> = mutex.withLock {
|
||||
try {
|
||||
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||
|
||||
val response = when (method.uppercase()) {
|
||||
"POST" -> client.post(url) {
|
||||
url {
|
||||
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||
}
|
||||
when {
|
||||
jsonBody != null -> {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
formBody != null -> {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody(formBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> client.get(url) {
|
||||
url {
|
||||
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val body = response.bodyAsText()
|
||||
val json = Json.parseToJsonElement(body).jsonObject
|
||||
Result.success(json)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本内容
|
||||
*/
|
||||
suspend fun fetchText(path: String): Result<String> = mutex.withLock {
|
||||
try {
|
||||
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||
val response = client.get(url)
|
||||
Result.success(response.bodyAsText())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
suspend fun logout() = mutex.withLock {
|
||||
try {
|
||||
// 清除 cookies
|
||||
cookieStorage.getCookies(URLBuilder(baseUrl).build())
|
||||
_isLoggedIn.value = false
|
||||
println("[Sage] Logged out")
|
||||
} catch (e: Exception) {
|
||||
println("[Sage] Logout error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭客户端
|
||||
*/
|
||||
fun close() {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
@ -36,11 +36,10 @@ fun RenderInputWidget(widget: BricksWidget) {
|
||||
|
||||
when (inputType) {
|
||||
"password" -> {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
visualTransformation = if (!visible) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
@ -63,7 +62,6 @@ fun RenderInputWidget(widget: BricksWidget) {
|
||||
|
||||
@Composable
|
||||
fun RenderTooltipWidget(widget: BricksWidget) {
|
||||
// Simplified tooltip - in real app would use TooltipBox
|
||||
val text = WidgetOptions.getString(widget.options, "text", "")
|
||||
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@ -1,72 +1,4 @@
|
||||
package com.bricks.mp.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bricks.mp.core.BricksWidget
|
||||
import com.bricks.mp.core.WidgetOptions
|
||||
|
||||
/**
|
||||
* 布局组件
|
||||
* HBox -> Row, VBox -> Column, Filler -> Spacer
|
||||
*/
|
||||
@Composable
|
||||
fun RenderLayoutWidget(widget: BricksWidget) {
|
||||
when (widget.widgettype) {
|
||||
"HBox", "FHBox" -> {
|
||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||
"center" -> Alignment.CenterVertically
|
||||
"end" -> Alignment.Bottom
|
||||
else -> Alignment.Top
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp),
|
||||
verticalAlignment = align,
|
||||
horizontalArrangement = when (widget.widgettype) {
|
||||
"FHBox" -> Arrangement.SpaceBetween
|
||||
else -> Arrangement.Start
|
||||
}
|
||||
) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
||||
}
|
||||
}
|
||||
"VBox", "FVBox" -> {
|
||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||
"center" -> Alignment.CenterHorizontally
|
||||
"end" -> Alignment.End
|
||||
else -> Alignment.Start
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp),
|
||||
horizontalAlignment = align,
|
||||
verticalArrangement = when (widget.widgettype) {
|
||||
"FVBox" -> Arrangement.SpaceBetween
|
||||
else -> Arrangement.Top
|
||||
}
|
||||
) {
|
||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
||||
}
|
||||
}
|
||||
"Filler", "HFiller" -> {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
"VFiller" -> {
|
||||
Spacer(modifier = Modifier.height(WidgetOptions.getInt(widget.options, "height", 16).dp))
|
||||
}
|
||||
"ResponsiveBox" -> {
|
||||
BoxWithConstraints {
|
||||
if (maxWidth > 600.dp) {
|
||||
Row { widget.subwidgets.forEach { RenderWidget(it) } }
|
||||
} else {
|
||||
Column { widget.subwidgets.forEach { RenderWidget(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Layout widgets are now rendered inline in BricksRenderer.kt
|
||||
// This file is kept for backward compatibility
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user