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:
AskaEth committed 2026-10-03 21:31:09 +08:00
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) }