merge: W4 — 概览页「按路径」分组折叠(可读性)

This commit is contained in:
AskaEth committed 2026-10-04 18:09:18 +08:00
commit a6b4bae2a0
3 files changed
+562 -2

No files matched your search

@@ -13,13 +13,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.PathGroup
import top.yeij.cyrene.domain.admin.PathHit
import top.yeij.cyrene.domain.admin.PathRow
import top.yeij.cyrene.domain.admin.PathStats
import top.yeij.cyrene.domain.admin.formatCounts
import top.yeij.cyrene.domain.admin.formatDouble
import top.yeij.cyrene.domain.admin.formatNanos
@@ -28,16 +38,28 @@ 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.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.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
* 概览页:`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) }
// 折叠状态放在 LazyColumn 之外:行随滚动回收,状态不能挂在 item 里。
// 只记**用户动过的组**,其余按分组默认档(见 PathStats.expanded),刷新不会把大组锁在打开状态。
val toggledGroups = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(Unit) { reload() }
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
@@ -72,6 +94,13 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -
}
}
}
item(key = "__paths__") {
// metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」,
// 再给一个空列表就是把失败画成「没有路径」。
data.metrics?.let { metrics ->
PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups)
}
}
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
when {
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
@@ -103,12 +132,112 @@ private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = l
"子代理事件" 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 { "-" },
)
/**
* 「按路径」区块:分组 + 可折叠(分组规则与排序全在 [PathStats],这里只负责画)。
*
* 首屏可读性靠两件事:**条数多的大组默认收起**(真实响应里 15 条 admin 路径收成一行组头)、
* 小组默认展开(省一次点击);行里同时给「条数」与「次数」,因为「15 条路径」和「283 次命中」
* 是两个不同的信息。
*/
@Composable
private fun PathStatsSection(perPath: Map<String, Long>, toggled: MutableMap<String, Boolean>) {
val groups = remember(perPath) { PathStats.groupPaths(perPath) }
val pathCount = groups.sumOf { it.pathCount }
val hits = groups.sumOf { it.hits }
// 这一行每次重组都要重算(toggled 是快照状态,读它才能跟着点按更新);纯计算,成本可忽略。
val rows = PathStats.rows(groups, toggled)
AdminSectionTitle("按路径($pathCount 条 · $hits 次)")
if (rows.isEmpty()) {
// 走到这里说明 metrics 请求**成功**了而 `perPath` 为空——成功后的空,不是失败。
AdminInlineHint("暂无路径统计")
return
}
PreferenceCard {
Column {
rows.forEachIndexed { index, row ->
if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
when (row) {
is PathRow.Header -> PathGroupRow(
group = row.group,
expanded = PathStats.expanded(row.group, toggled),
onToggle = { toggled[row.group.prefix] = !PathStats.expanded(row.group, toggled) },
)
is PathRow.Leaf -> PathLeafRow(hit = row.hit, nested = row.nested)
}
}
}
}
}
/** 组头:前缀 + 「N 条 · M 次」+ 箭头(展开时转向下)。整行可点,是唯一的折叠入口。 */
@Composable
private fun PathGroupRow(group: PathGroup, expanded: Boolean, onToggle: () -> Unit) {
BasicComponent(
onClick = onToggle,
endActions = {
Text(
text = "${group.pathCount} 条 · ${group.hits} 次",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
Icon(
imageVector = MiuixIcons.ChevronForward,
contentDescription = if (expanded) "收起" else "展开",
modifier = Modifier
.padding(start = 6.dp)
.rotate(if (expanded) 90f else 0f),
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
) {
Text(
text = group.prefix,
fontSize = MiuixTheme.textStyles.main.fontSize,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* 路径行:左路径、右计数。
*
* 路径用**等宽字体**(路径是给人对比字符用的,等宽最好扫读),字号取 `footnote1` 与计数一致;
* 过长时**中间省略**——尾部(资源名)才是区分同类路径的部分,尾部省略等于把两条路径画成一样。
*/
@Composable
private fun PathLeafRow(hit: PathHit, nested: Boolean) {
BasicComponent(
endActions = {
Text(
text = hit.count.toString(),
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
},
) {
Text(
text = hit.display,
modifier = if (nested) Modifier.padding(start = 12.dp) else Modifier,
fontSize = MiuixTheme.textStyles.footnote1.fontSize,
fontFamily = FontFamily.Monospace,
color = MiuixTheme.colorScheme.onSurfaceContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
@Composable
private fun TraceSpanCard(span: AdminTraceSpanDto) {
AdminTextCard(