feat(admin): 概览「按路径」改成分组可折叠列表 —— 26 条路径不再挤成一行

`perPath`(真实响应 26 条)此前被 `formatCounts` 压成一行右对齐文本,手机上极密。
本次只改呈现层,取数(DTO / Retrofit / ViewModel)一字未动。

- domain/admin/AdminPathStats.kt(新增,纯函数):分组键 = **剥掉 `/api/v1` 后的首段**
  (`/api/v1/admin/llm/catalog` → `/admin`,`/sw.js` → `/sw.js`,`/` → `/`)。
  规则是纯字符串推导、**没有任何路径清单**:服务端加路由不用改客户端
  (`/api/v1/quantum/*` 自动成 `/quantum` 组);逐段比较,`/api/v1x/foo` 不会被误剥成 `/x`。
  剥离只作用于分组键,行里显示的仍是完整路径。
  排序:组按命中次数倒序、组内同样倒序,**两处同数都按路径升序**——指标页每次刷新都会重排,
  没有稳定次序读者就跟丢了。`hits`(次数)与 `pathCount`(条数)分开,是组头的两个数。
  长路径(段数 > 4)展示时折叠中间段、保留尾部 3 段:`…/admin/observability/metrics`;
  尾部才是区分同类路径的部分,整串在手机上会被尾部省略号吃掉、两条路径就长得一样了。
- ui/admin/AdminOverviewScreen.kt:「按路径」从 `countRows` 里挪出来单独成块——
  区块标题给「26 条 · 326 次」,卡片内一行一组,组头「N 条 · M 次」+ 可旋转箭头,
  整行可点即展开/收起;组内一行一条路径,右侧计数右对齐、路径等宽字体 + 中间省略。
  **条数 > 4 的组默认收起**(真实数据里 15 条 admin 路径收成一行组头),小分组默认展开省一次点击;
  折叠状态只记**用户动过的组**(`remember { mutableStateMapOf() }`,放在 LazyColumn 之外),
  刷新拿到新数据不会被上一轮点按锁死。
  只用 Miuix 现成组件(`PreferenceCard` + `BasicComponent` + `HorizontalDivider` +
  `MiuixIcons.ChevronForward` 旋转 90°),**没有自造折叠控件**;路径行用内容重载自己画 Text
  才能拿到 `maxLines = 1` + `MiddleEllipsis`(收 `String` 的那个重载只会尾部省略)。
  失败态不变:`metrics` 缺席时整块不渲染(上面那张「指标不可用」卡已经说明了原因),
  只有 `perPath` **请求成功且为空**时才给「暂无路径统计」。

测试:353 → 370 全绿(+17,AdminPathStatsTest):空输入 / 单条(不产生组头)/ 真实 26 条 →
10 组且总次数 326 = 同一份真实响应里的 `totalRequests`(分组不吞路径也不重算次数)/
未知前缀自动成组 / 基址逐段剥离的边界(`/api/v1x/foo`、基址本身)/ 组与组内的排序与并列次序 /
长路径折叠(两条兄弟路径折叠后仍不同)/ 折叠默认值的阈值边界(4 条展开、5 条收起)/
显式切换双向覆盖默认。

未验证(无模拟器、也不许碰 adb,只有构建 + 单测):
- 观感(组头取 `primary`、路径等宽字号取 `footnote1`、子行缩进 12dp、箭头旋转)**没有截图**,
  取舍待主人拍板;
- `BasicComponent` 对长路径会不会折行没有实机确认,所以路径文本额外做了「段级中间折叠」,
  不依赖库的省略行为;
- 深色模式未单独验证,颜色全部取自 `MiuixTheme.colorScheme`(与页面其它卡片同源)。
This commit is contained in:
AskaEth committed 2026-10-04 18:07:22 +08:00
1 parent 664fd8dcef
commit f91a8558ea
3 files changed
+562 -2

No files matched your search

@@ -0,0 +1,156 @@
package top.yeij.cyrene.domain.admin
/**
* `perPath` 的一项:路径原文 + 命中次数 + 展示文本。
*
* [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴),
* 手机上要读的那一份在 [display](见 [PathStats.displayPath])。
*/
data class PathHit(
val path: String,
val count: Long,
val display: String,
)
/**
* 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。
* [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事**
* (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。
*/
data class PathGroup(
val prefix: String,
val paths: List<PathHit>,
) {
val hits: Long get() = paths.sumOf { it.count }
val pathCount: Int get() = paths.size
/**
* 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头,
* 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。
*/
val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD
}
/**
* 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。
* 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。
*/
sealed interface PathRow {
data class Header(val group: PathGroup) : PathRow
/** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */
data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow
}
/**
* 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。
*
* ## 为什么要客户端分组
* 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射——
* 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]),
* 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**:
*
* **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`,
* `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。
*
* - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端
* (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。
* - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度;
* 剥离**只影响分组键**,行里显示的仍是完整路径。
* - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个
* 只有一条路径的组,组头与内容重复,比不分组还难读。
* - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**:
* 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上
* 会被省略号吃掉尾部、丢掉区分度。
*
* 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序,
* 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。
*/
object PathStats {
/** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */
const val API_BASE_PREFIX = "/api/v1"
/** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */
const val COLLAPSE_THRESHOLD = 4
/** 段数超过它的路径在展示时折叠中间段。 */
const val MAX_DISPLAY_SEGMENTS = 4
/** 折叠后保留的尾部段数。 */
const val RETAINED_TAIL_SEGMENTS = 3
/** 根路径(`/`)与异常空串的组名。 */
private const val ROOT_PREFIX = "/"
private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX)
/**
* `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。
* 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。
*/
fun groupPaths(perPath: Map<String, Long>): List<PathGroup> =
perPath.entries
.groupBy(
keySelector = { groupPrefix(it.key) },
valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) },
)
.map { (prefix, paths) ->
PathGroup(
prefix = prefix,
paths = paths.sortedWith(compareByDescending<PathHit> { it.count }.thenBy { it.path }),
)
}
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
/**
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
*/
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
toggled[group.prefix] ?: group.expandedByDefault
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
groups.flatMap { group ->
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
if (group.pathCount == 1) {
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
} else {
buildList<PathRow> {
add(PathRow.Header(group))
if (expanded(group, toggled)) {
group.paths.forEach { add(PathRow.Leaf(it, nested = true)) }
}
}
}
}
/**
* 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化,
* 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段,
* 前缀 `…/` 明确表示左边还有内容被省掉。
*/
fun displayPath(path: String): String {
val parts = segments(path)
if (parts.size <= MAX_DISPLAY_SEGMENTS) return path
return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/")
}
/** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */
private fun groupPrefix(path: String): String {
val parts = segments(path)
if (parts.isEmpty()) return ROOT_PREFIX
val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) {
parts.drop(API_BASE_SEGMENTS.size)
} else {
parts
}
return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX
}
private fun segments(path: String): List<String> = path.split('/').filter { it.isNotEmpty() }
}