feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读, 不调用任何 /admin/* 写端点。 - data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository, 经 ApiClientFactory 创建以复用既有 token / 401 处理 - domain:AdminAccess.kt 为权限真源(纯函数,便于单测); AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析 - ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举), 四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent - RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档—— viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+ 服务端 403 也渲染「无权限」态,三层 fail-closed; 任何失败都不渲染成「暂无数据」 为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点 均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②, 凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。 测试:74 个单测全绿,其中新增 29 个 (AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
This commit is contained in:
1 parent
9bc45dc315
commit
a7b3452e6d
19 files changed
+1672
-6
No files matched your search
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.formatEpochMillis
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
|
||||
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.backthink.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Backthink, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { records ->
|
||||
if (records.isEmpty()) {
|
||||
AdminCenterHint("暂无后台思考记录")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = records, key = { it.id }) { record -> BackthinkCard(record) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackthinkCard(record: AdminBackthinkItemDto) {
|
||||
AdminTextCard(
|
||||
title = orDash(record.trigger),
|
||||
body = backthinkBody(record),
|
||||
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
|
||||
)
|
||||
}
|
||||
|
||||
private fun backthinkBody(record: AdminBackthinkItemDto): String = buildList {
|
||||
record.conclusion?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
add("通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}")
|
||||
}.joinToString("\n")
|
||||
@@ -0,0 +1,231 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
|
||||
import top.yukonga.miuix.kmp.icon.extended.Refresh
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。
|
||||
* 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminPageScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (onRefresh != null) {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminSectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */
|
||||
@Composable
|
||||
fun AdminCenterHint(
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
if (detail != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = detail,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */
|
||||
@Composable
|
||||
fun AdminInlineHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取数失败时的整页状态。
|
||||
* **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminFailureState(
|
||||
failure: AdminFailure?,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (failure) {
|
||||
is AdminFailure.Forbidden -> AdminCenterHint(
|
||||
title = "无权限",
|
||||
detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。",
|
||||
)
|
||||
|
||||
else -> AdminCenterHint(
|
||||
title = "加载失败",
|
||||
detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试",
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。
|
||||
* 顺序不能反——反了就会把 403 渲染成「暂无数据」。
|
||||
*/
|
||||
@Composable
|
||||
fun <T> AdminLoadShell(
|
||||
state: AdminUiState<T>,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val data = state.data
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry)
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
data == null -> AdminFailureState(state.failure, onRetry)
|
||||
else -> content(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */
|
||||
@Composable
|
||||
fun AdminValueRow(title: String, value: String, valueColor: Color? = null) {
|
||||
BasicComponent(
|
||||
title = title,
|
||||
endActions = {
|
||||
Text(
|
||||
text = value,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */
|
||||
@Composable
|
||||
fun AdminMetricCard(rows: List<Pair<String, String>>) {
|
||||
PreferenceCard {
|
||||
Column {
|
||||
rows.forEachIndexed { index, (label, value) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
AdminValueRow(title = label, value = value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
|
||||
@Composable
|
||||
fun AdminTextCard(
|
||||
title: String,
|
||||
body: String,
|
||||
trailing: String? = null,
|
||||
trailingColor: Color? = null,
|
||||
) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
|
||||
if (trailing != null) {
|
||||
Text(
|
||||
text = trailing,
|
||||
modifier = Modifier.widthIn(max = 160.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (body.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.canAccess
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。
|
||||
*
|
||||
* 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由
|
||||
* [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支,
|
||||
* 不需要改动本文件的布局。
|
||||
*
|
||||
* 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**),
|
||||
* ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminHomeScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
val session by container.authManager.state.collectAsState()
|
||||
val rawRole = (session as? SessionState.LoggedIn)?.role
|
||||
val role = AdminRole.of(rawRole)
|
||||
var openPage by remember { mutableStateOf<AdminPage?>(null) }
|
||||
val viewModel: AdminViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { AdminViewModel(container) } },
|
||||
)
|
||||
|
||||
val page = openPage
|
||||
BackHandler(enabled = page != null) { openPage = null }
|
||||
|
||||
if (page == null) {
|
||||
AdminIndexScreen(
|
||||
rawRole = rawRole,
|
||||
role = role,
|
||||
onOpen = { openPage = it },
|
||||
onBack = onBack,
|
||||
)
|
||||
} else {
|
||||
AdminPageScreen(
|
||||
page = page,
|
||||
role = role,
|
||||
viewModel = viewModel,
|
||||
onBack = { openPage = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */
|
||||
@Composable
|
||||
private fun AdminIndexScreen(
|
||||
rawRole: String?,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = "只读监控 · 当前角色 ${orDash(rawRole)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp),
|
||||
)
|
||||
AdminSectionTitle("监控页")
|
||||
AdminPage.entries.forEach { page ->
|
||||
PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminPageRow(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
) {
|
||||
if (canAccess(role, page)) {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = page.summary,
|
||||
onClick = { onOpen(page) },
|
||||
endActions = {
|
||||
Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = "需要 owner 或 admin 权限",
|
||||
endActions = {
|
||||
Text(
|
||||
text = "无权限",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */
|
||||
@Composable
|
||||
private fun AdminPageScreen(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
viewModel: AdminViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack)
|
||||
AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack)
|
||||
AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack)
|
||||
AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.memoryScopeLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。
|
||||
* `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.memories.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Memories, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { memories ->
|
||||
if (memories.isEmpty()) {
|
||||
AdminCenterHint("暂无记忆")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = memories, key = { it.id }) { memory ->
|
||||
AdminTextCard(
|
||||
// scope 是开放集合:未知取值原样显示。
|
||||
title = orDash(memoryScopeLabel(memory.scope)),
|
||||
body = memory.text,
|
||||
trailing = memory.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.formatCounts
|
||||
import top.yeij.cyrene.domain.admin.formatDouble
|
||||
import top.yeij.cyrene.domain.admin.formatNanos
|
||||
import top.yeij.cyrene.domain.admin.formatProps
|
||||
import top.yeij.cyrene.domain.admin.formatRfc3339
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.traceKindLabel
|
||||
import top.yeij.cyrene.domain.admin.traceStatusLabel
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.overview.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Overview, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
val data = state.data
|
||||
val failure = state.failure
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload)
|
||||
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
|
||||
data == null || (data.metrics == null && data.trace.isEmpty()) ->
|
||||
AdminFailureState(failure, reload)
|
||||
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
item(key = "__metrics__") {
|
||||
Column {
|
||||
AdminSectionTitle("指标")
|
||||
if (data.metrics != null) {
|
||||
AdminMetricCard(metricRows(data.metrics))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AdminMetricCard(countRows(data.metrics))
|
||||
} else {
|
||||
AdminTextCard("指标不可用", failureMessage(failure))
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
|
||||
when {
|
||||
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
|
||||
failure != null -> item(key = "__trace_error__") {
|
||||
AdminTextCard("trace 不可用", failureMessage(failure))
|
||||
}
|
||||
|
||||
data.trace.isEmpty() -> item(key = "__trace_empty__") {
|
||||
AdminInlineHint("暂无 trace span")
|
||||
}
|
||||
|
||||
else -> items(items = data.trace, key = { it.id }) { span ->
|
||||
TraceSpanCard(span)
|
||||
}
|
||||
}
|
||||
item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"总请求数" to metrics.totalRequests.toString(),
|
||||
"QPS(近 1 秒)" to formatDouble(metrics.qps),
|
||||
"平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms",
|
||||
"最大延迟" to "${metrics.maxLatencyMs} ms",
|
||||
"LLM 调用" to metrics.llmCalls.toString(),
|
||||
"子代理事件" to metrics.subagentEvents.toString(),
|
||||
)
|
||||
|
||||
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"按方法" to formatCounts(metrics.perMethod).ifBlank { "-" },
|
||||
"按路径" to formatCounts(metrics.perPath).ifBlank { "-" },
|
||||
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TraceSpanCard(span: AdminTraceSpanDto) {
|
||||
AdminTextCard(
|
||||
title = span.name.ifBlank { span.id },
|
||||
body = traceBody(span),
|
||||
trailing = orDash(traceStatusLabel(span.status)),
|
||||
trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun traceBody(span: AdminTraceSpanDto): String = buildList {
|
||||
add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}")
|
||||
add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}")
|
||||
span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") }
|
||||
span.parentId?.let { add("父 span $it") }
|
||||
formatProps(span.props)?.let { add("props $it") }
|
||||
span.events?.forEach { event ->
|
||||
val props = formatProps(event.props)?.let { " $it" }.orEmpty()
|
||||
add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props")
|
||||
}
|
||||
}.joinToString("\n")
|
||||
|
||||
private fun failureMessage(failure: AdminFailure?): String =
|
||||
(failure as? AdminFailure.Other)?.message ?: "请求失败,请重试"
|
||||
@@ -0,0 +1,112 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaThinkingDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.moodLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.personaModeLabel
|
||||
import top.yeij.cyrene.domain.admin.triStateLabel
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */
|
||||
@Composable
|
||||
fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.personas.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Personas, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { personas ->
|
||||
if (personas.isEmpty()) {
|
||||
AdminCenterHint("暂无人格")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = personas, key = { it.id }) { PersonaCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCard(persona: PersonaSummaryDto) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main)
|
||||
if (persona.isDefault) {
|
||||
Text(
|
||||
text = " · 默认",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
PersonaDetail("id", persona.id)
|
||||
PersonaDetail("character", persona.character.ifBlank { "-" })
|
||||
// mode 是开放集合:未知取值原样显示。
|
||||
PersonaDetail("mode", personaModeLabel(persona.mode).ifBlank { "-" })
|
||||
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
|
||||
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
|
||||
PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无")
|
||||
PersonaDetail("思考", thinkingLabel(persona.thinking))
|
||||
persona.avatar?.let { PersonaDetail("avatar", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaDetail(label: String, value: String) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$label:$value",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态,
|
||||
* null 必须显示成「未配置」,不能拍平成「不显示」。
|
||||
*/
|
||||
private fun thinkingLabel(thinking: PersonaThinkingDto?): String {
|
||||
if (thinking == null) return "未配置"
|
||||
return listOf(
|
||||
"budgetTokens ${thinking.budgetTokens}",
|
||||
"effort ${orDash(thinking.effort)}",
|
||||
"mode ${orDash(thinking.mode)}",
|
||||
"showToUser ${triStateLabel(thinking.showToUser)}",
|
||||
).joinToString(" · ")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.canAccess
|
||||
import top.yeij.cyrene.domain.admin.toAdminFailure
|
||||
|
||||
/**
|
||||
* 单个管理页的取数状态。
|
||||
* [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**——
|
||||
* 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。
|
||||
*/
|
||||
data class AdminUiState<T>(
|
||||
val loading: Boolean = false,
|
||||
val data: T? = null,
|
||||
val failure: AdminFailure? = null,
|
||||
)
|
||||
|
||||
/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
|
||||
data class AdminOverviewData(
|
||||
val metrics: AdminMetricsDto? = null,
|
||||
val trace: List<AdminTraceSpanDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 管理只读监控的四页取数(api-client §2)。
|
||||
*
|
||||
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
|
||||
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
|
||||
*/
|
||||
class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _overview = MutableStateFlow(AdminUiState<AdminOverviewData>())
|
||||
val overview: StateFlow<AdminUiState<AdminOverviewData>> = _overview.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow(AdminUiState<List<PersonaSummaryDto>>())
|
||||
val personas: StateFlow<AdminUiState<List<PersonaSummaryDto>>> = _personas.asStateFlow()
|
||||
|
||||
private val _memories = MutableStateFlow(AdminUiState<List<MemorySummaryDto>>())
|
||||
val memories: StateFlow<AdminUiState<List<MemorySummaryDto>>> = _memories.asStateFlow()
|
||||
|
||||
private val _backthink = MutableStateFlow(AdminUiState<List<AdminBackthinkItemDto>>())
|
||||
val backthink: StateFlow<AdminUiState<List<AdminBackthinkItemDto>>> = _backthink.asStateFlow()
|
||||
|
||||
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
|
||||
fun load(page: AdminPage, role: AdminRole) {
|
||||
if (!canAccess(role, page)) {
|
||||
deny(page)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (page) {
|
||||
AdminPage.Overview -> loadOverview()
|
||||
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
|
||||
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
|
||||
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 */
|
||||
private suspend fun loadOverview() {
|
||||
_overview.value = _overview.value.copy(loading = true, failure = null)
|
||||
val repository = container.adminRepository
|
||||
val (metrics, trace) = coroutineScope {
|
||||
val metricsCall = async { repository.metrics() }
|
||||
val traceCall = async { repository.trace() }
|
||||
metricsCall.await() to traceCall.await()
|
||||
}
|
||||
val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull())
|
||||
.map { it.toAdminFailure() }
|
||||
_overview.value = AdminUiState(
|
||||
data = AdminOverviewData(
|
||||
metrics = metrics.getOrNull(),
|
||||
trace = trace.getOrNull().orEmpty(),
|
||||
),
|
||||
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> loadList(
|
||||
target: MutableStateFlow<AdminUiState<List<T>>>,
|
||||
fetch: suspend () -> Result<List<T>>,
|
||||
) {
|
||||
target.value = target.value.copy(loading = true, failure = null)
|
||||
fetch()
|
||||
.onSuccess { list -> target.value = AdminUiState(data = list) }
|
||||
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
|
||||
}
|
||||
|
||||
private fun deny(page: AdminPage) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.ui.admin.AdminHomeScreen
|
||||
import top.yeij.cyrene.ui.common.MiuixDrawer
|
||||
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
||||
import top.yeij.cyrene.ui.sessions.SessionsViewModel
|
||||
import top.yeij.cyrene.ui.settings.SettingsScreen
|
||||
|
||||
/**
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页。
|
||||
* 返回键逐层回退:设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
|
||||
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
*/
|
||||
@Composable
|
||||
fun ChatHomeScreen(
|
||||
@@ -31,18 +32,25 @@ fun ChatHomeScreen(
|
||||
val state by sessionsViewModel.state.collectAsState()
|
||||
var drawerOpen by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showAdmin by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = showAdmin) { showAdmin = false }
|
||||
BackHandler(enabled = showSettings) { showSettings = false }
|
||||
BackHandler(enabled = drawerOpen) { drawerOpen = false }
|
||||
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
when {
|
||||
showAdmin -> AdminHomeScreen(
|
||||
container = container,
|
||||
onBack = { showAdmin = false },
|
||||
)
|
||||
|
||||
showSettings -> SettingsScreen(
|
||||
container = container,
|
||||
onBack = { showSettings = false },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
} else {
|
||||
MiuixDrawer(
|
||||
|
||||
else -> MiuixDrawer(
|
||||
open = drawerOpen,
|
||||
onOpenChange = { drawerOpen = it },
|
||||
content = {
|
||||
@@ -63,6 +71,10 @@ fun ChatHomeScreen(
|
||||
onCreate = sessionsViewModel::createSession,
|
||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
||||
onOpenIot = { /* P2:IoT 页面 */ },
|
||||
onOpenAdmin = {
|
||||
drawerOpen = false
|
||||
showAdmin = true
|
||||
},
|
||||
onOpenSettings = {
|
||||
drawerOpen = false
|
||||
showSettings = true
|
||||
|
||||
@@ -42,6 +42,7 @@ fun ColumnScope.SessionDrawerContent(
|
||||
onCreate: () -> Unit,
|
||||
onDelete: (SessionItem) -> Unit,
|
||||
onOpenIot: () -> Unit,
|
||||
onOpenAdmin: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
|
||||
@@ -98,6 +99,8 @@ fun ColumnScope.SessionDrawerContent(
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PreferenceCard { BasicComponent(title = "管理员监控", onClick = onOpenAdmin) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard { BasicComponent(title = "IoT(规划中)", onClick = onOpenIot) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard { BasicComponent(title = "App 设置", onClick = onOpenSettings) }
|
||||
|
||||
Reference in new issue
Block a user