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

@@ -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() }
}