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,275 @@
package top.yeij.cyrene.domain.admin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「按路径」分组与折叠模型(C-2 概览页)。
*
* `realPerPath` 是**真实响应体**里的 `perPath`(26 条,与 `AdminDtoRealPayloadTest` 同一份样本),
* 不是编造的样例——分组规则必须在真实数据上成立才算数。
*/
class AdminPathStatsTest {
private val realPerPath = mapOf(
"/" to 2L,
"/api/v1/admin/automations" to 17L,
"/api/v1/admin/backthink" to 1L,
"/api/v1/admin/channels" to 30L,
"/api/v1/admin/iot" to 34L,
"/api/v1/admin/llm" to 18L,
"/api/v1/admin/llm/catalog" to 2L,
"/api/v1/admin/memories" to 21L,
"/api/v1/admin/observability/metrics" to 24L,
"/api/v1/admin/observability/trace" to 1L,
"/api/v1/admin/personas" to 31L,
"/api/v1/admin/plugins" to 23L,
"/api/v1/admin/plugins/installed" to 17L,
"/api/v1/admin/rag" to 17L,
"/api/v1/admin/sessions" to 30L,
"/api/v1/admin/users" to 17L,
"/api/v1/auth/login" to 5L,
"/api/v1/health" to 27L,
"/api/v1/meta/enums" to 2L,
"/api/v1/personas" to 1L,
"/api/v1/sessions" to 1L,
"/api/v1/sessions/web/messages" to 1L,
"/assets/index-DrDuaeXG.js" to 1L,
"/assets/index-SA9l26Q_.css" to 1L,
"/sw.js" to 1L,
"/ws/chat" to 1L,
)
// ------------------------------------------------------------------
// 空输入 / 单条
// ------------------------------------------------------------------
@Test
fun `empty input yields no groups and no rows`() {
assertTrue(PathStats.groupPaths(emptyMap()).isEmpty())
assertTrue(PathStats.rows(emptyList()).isEmpty())
}
@Test
fun `a single path is one group and renders as a headerless row`() {
val groups = PathStats.groupPaths(mapOf("/api/v1/health" to 7L))
assertEquals(1, groups.size)
assertEquals("/health", groups[0].prefix)
assertEquals(1, groups[0].pathCount)
assertEquals(7L, groups[0].hits)
// 只有一条路径的组不产生组头:组头与内容完全重复,只会让列表变长。
assertEquals(listOf(PathRow.Leaf(PathHit("/api/v1/health", 7L, "/api/v1/health"), false)), PathStats.rows(groups))
}
// ------------------------------------------------------------------
// 分组规则(无路径清单)
// ------------------------------------------------------------------
@Test
fun `grouping keys off the first segment below the api base, not a hardcoded route list`() {
val groups = PathStats.groupPaths(
mapOf(
"/api/v1/quantum/entangle" to 3L, // 客户端不认识的新路由:照样自动成组
"/api/v1/admin/automations" to 1L,
),
)
assertEquals(listOf("/quantum", "/admin"), groups.map { it.prefix })
}
@Test
fun `api base prefix is stripped for grouping only, the row keeps the full path`() {
val group = PathStats.groupPaths(mapOf("/api/v1/admin/llm/catalog" to 2L)).single()
assertEquals("/admin", group.prefix)
assertEquals("/api/v1/admin/llm/catalog", group.paths.single().path)
}
@Test
fun `a path that only looks like the base is not stripped`() {
// 逐段比较:`/api/v1x/...` 不是 `/api/v1` 下的路径,不能被剥成 `/x`。
assertEquals("/api", PathStats.groupPaths(mapOf("/api/v1x/foo" to 1L)).single().prefix)
// 基址本身(没有更深的一段)自成一档,而不是落进空字符串。
assertEquals("/api/v1", PathStats.groupPaths(mapOf("/api/v1" to 1L)).single().prefix)
}
@Test
fun `root and non-api paths each get their own group`() {
val groups = PathStats.groupPaths(
mapOf(
"/" to 2L,
"" to 1L,
"/assets/index-DrDuaeXG.js" to 1L,
"/assets/index-SA9l26Q_.css" to 1L,
"/sw.js" to 1L,
"/ws/chat" to 1L,
),
)
assertEquals(listOf("/", "/assets", "/sw.js", "/ws"), groups.map { it.prefix })
assertEquals(2, groups.first { it.prefix == "/assets" }.pathCount)
assertEquals(2, groups.first { it.prefix == "/" }.pathCount)
}
// ------------------------------------------------------------------
// 真实 26 条
// ------------------------------------------------------------------
@Test
fun `real payload groups into ten prefixes without losing a path or a hit`() {
val groups = PathStats.groupPaths(realPerPath)
assertEquals(
listOf("/admin", "/health", "/auth", "/", "/assets", "/meta", "/sessions", "/personas", "/sw.js", "/ws"),
groups.map { it.prefix },
)
assertEquals(26, groups.sumOf { it.pathCount })
// 与同一份真实响应里的 totalRequests 一致:分组既不吞路径也不重算次数。
assertEquals(326L, groups.sumOf { it.hits })
assertEquals(15, groups.first().pathCount)
assertEquals(283L, groups.first().hits)
}
@Test
fun `groups are ordered by hits, ties by prefix`() {
val groups = PathStats.groupPaths(realPerPath)
val hits = groups.map { it.hits }
assertEquals(hits.sortedDescending(), hits)
// 并列的 2 次:按前缀升序,保证刷新前后顺序稳定。
assertEquals(listOf("/", "/assets", "/meta", "/sessions"), groups.filter { it.hits == 2L }.map { it.prefix })
}
@Test
fun `paths inside a group are ordered by count, ties by path`() {
val admin = PathStats.groupPaths(realPerPath).first().paths
val counts = admin.map { it.count }
assertEquals(counts.sortedDescending(), counts)
assertEquals(
listOf(
"/api/v1/admin/automations",
"/api/v1/admin/plugins/installed",
"/api/v1/admin/rag",
"/api/v1/admin/users",
),
admin.filter { it.count == 17L }.map { it.path },
)
assertEquals("/api/v1/admin/iot", admin.first().path)
}
// ------------------------------------------------------------------
// 长路径的展示文本
// ------------------------------------------------------------------
@Test
fun `deep paths collapse the middle and keep the tail`() {
assertEquals("…/admin/observability/metrics", PathStats.displayPath("/api/v1/admin/observability/metrics"))
assertEquals("…/sessions/web/messages", PathStats.displayPath("/api/v1/sessions/web/messages"))
}
@Test
fun `paths within the segment budget are rendered verbatim`() {
// 不归一化:尾斜杠、文件名里的点、问号都原样留着(键名就是后端给的原串)。
assertEquals("/api/v1/auth/login", PathStats.displayPath("/api/v1/auth/login"))
assertEquals("/assets/index-DrDuaeXG.js", PathStats.displayPath("/assets/index-DrDuaeXG.js"))
assertEquals("/sw.js", PathStats.displayPath("/sw.js"))
assertEquals("/", PathStats.displayPath("/"))
assertEquals("", PathStats.displayPath(""))
}
@Test
fun `collapsing never makes two sibling paths look identical`() {
val a = PathStats.displayPath("/api/v1/admin/observability/metrics")
val b = PathStats.displayPath("/api/v1/admin/observability/trace")
assertTrue(a, a != b)
assertTrue(a, a.endsWith("/metrics"))
assertTrue(b, b.endsWith("/trace"))
}
@Test
fun `display text is wired into the grouped rows`() {
val hit = PathStats.groupPaths(realPerPath)
.flatMap { it.paths }
.first { it.path == "/api/v1/admin/observability/metrics" }
assertEquals("…/admin/observability/metrics", hit.display)
}
// ------------------------------------------------------------------
// 折叠状态
// ------------------------------------------------------------------
@Test
fun `big groups collapse by default, small ones expand`() {
val groups = PathStats.groupPaths(realPerPath)
val admin = groups.first { it.prefix == "/admin" }
assertFalse(admin.expandedByDefault)
assertTrue(groups.first { it.prefix == "/assets" }.expandedByDefault)
// 阈值边界:恰好 [PathStats.COLLAPSE_THRESHOLD] 条仍展开,多一条就收起。
val atThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD).map { "/api/v1/t/p$it" })
val aboveThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD + 1).map { "/api/v1/t/p$it" })
assertTrue(atThreshold.expandedByDefault)
assertFalse(aboveThreshold.expandedByDefault)
}
@Test
fun `default rows show only the header of a collapsed group`() {
val rows = PathStats.rows(PathStats.groupPaths(realPerPath))
// 15 条 admin 路径 + 其余散条都在,但首屏只有组头承载它们。
assertEquals(14, rows.size)
assertEquals(PathRow.Header(PathStats.groupPaths(realPerPath).first()), rows.first())
assertEquals(1, rows.count { it is PathRow.Header && it.group.prefix == "/admin" })
assertTrue(rows.none { it is PathRow.Leaf && it.hit.path.startsWith("/api/v1/admin/") })
// 小分组默认展开:组头之后紧跟它自己的子行。
val assetsHeader = rows.indexOfFirst { it is PathRow.Header && it.group.prefix == "/assets" }
assertTrue(rows[assetsHeader + 1] is PathRow.Leaf)
assertEquals(listOf(true, true), (assetsHeader + 1..assetsHeader + 2).map { (rows[it] as PathRow.Leaf).nested })
}
@Test
fun `explicit toggles win over the default in both directions`() {
val groups = PathStats.groupPaths(realPerPath)
val admin = groups.first { it.prefix == "/admin" }
val assets = groups.first { it.prefix == "/assets" }
// 收起的大组被点开:组头之后紧跟着它的 [admin.pathCount] 条子行(缩进)。
val opened = PathStats.rows(groups, mapOf("/admin" to true))
val adminHeader = opened.indexOfFirst { it is PathRow.Header && it.group.prefix == "/admin" }
val children = opened.subList(adminHeader + 1, adminHeader + 1 + admin.pathCount)
assertEquals(admin.paths.map { it.path }, children.map { (it as PathRow.Leaf).hit.path })
assertTrue(children.all { it is PathRow.Leaf && it.nested })
assertEquals("/api/v1/admin/iot", (opened[adminHeader + 1] as PathRow.Leaf).hit.path)
// 默认展开的小组被收起:只剩组头。
val closed = PathStats.rows(groups, mapOf("/assets" to false))
assertTrue(closed.none { it is PathRow.Leaf && it.hit.path.startsWith("/assets/") })
assertTrue(PathStats.expanded(assets, mapOf("/assets" to false)).not())
}
@Test
fun `rows keep the group order and never drop a path when everything is expanded`() {
val groups = PathStats.groupPaths(realPerPath)
val toggled = groups.associate { it.prefix to true }
val rows = PathStats.rows(groups, toggled)
// 只有多路径的组才有组头(单条组直接是一行路径),全部展开时行数 = 组头 + 全部路径。
val multiPathGroups = groups.filter { it.pathCount > 1 }
assertEquals(multiPathGroups.size + 26, rows.size)
assertEquals(
multiPathGroups.map { it.prefix },
rows.filterIsInstance<PathRow.Header>().map { it.group.prefix },
)
}
private fun group(prefix: String, paths: List<String>): PathGroup =
PathStats.groupPaths(paths.associateWith { 1L }).single().also { assertEquals(prefix, it.prefix) }
}