pi-skill-evolve 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  15. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  18. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
  19. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  20. package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
  21. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  22. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  23. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  24. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  25. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  26. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  27. package/extensions/skill-evolve/lib/web/server.ts +21 -1
  28. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  29. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  30. package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
  31. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  32. package/extensions/skill-evolve/lib/web/style.ts +171 -89
  33. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  34. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  35. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  36. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  37. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  38. package/extensions/skill-evolve/test/session-page.test.ts +200 -0
  39. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  40. package/extensions/skill-evolve/test/session-view.test.ts +180 -0
  41. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  42. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  43. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  44. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  45. package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
  46. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  47. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 「待处理」视图的数据:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队。
3
+ *
4
+ * 为什么从 data.ts 拆出来:`data.ts` 只留视图形状与聚合入口,四类条目的判定各有各的判据
5
+ * (活 pid、classifyStale、队列层级、run 状态),合在一起必然破 300 行闸门。
6
+ * 纪律不变:只读、坏数据当空(判不了的那一条退化成不出现,绝不整页 500)。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { QueueItem } from "../queue.ts";
11
+ import type { RunSummary } from "../runs.ts";
12
+ import { CHILD_LOG } from "../spawn-plan.ts";
13
+ import { classifyStale, readLogTail, readRunError } from "../stale.ts";
14
+ import type { WebStateDeps } from "./data.ts";
15
+ import { asNumber, asText } from "./json-read.ts";
16
+ import { sessionForRun, sessionIndex, sessionOfRun, type SessionRef } from "./run-session.ts";
17
+
18
+ /** 单 run 默认上限:与 DEFAULT_CONFIG.maxRuntimeMs 同值,面板只用来算「上限 N 分钟」 */
19
+ const DEFAULT_MAX_RUNTIME_MS = 20 * 60_000;
20
+
21
+ function defaultAlive(pid: number): boolean {
22
+ try {
23
+ process.kill(pid, 0); // 信号 0 只探测存在性,不真发信号
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** 待处理四来源:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队 */
31
+ export type PendingKind = "running" | "approval" | "stale" | "reviewed";
32
+
33
+ export interface PendingItem {
34
+ runId: string;
35
+ kind: PendingKind;
36
+ /** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
37
+ session: SessionRef;
38
+ skill: string;
39
+ at: number | null;
40
+ /** 展开后的纯文本(渲染层负责转义) */
41
+ detail: string;
42
+ }
43
+
44
+
45
+ /** 把毫秒压成「Xm Ys」:面板上人只关心量级,不关心毫秒 */
46
+ function duration(ms: number): string {
47
+ const total = Math.max(0, Math.floor(ms / 1000));
48
+ return `${Math.floor(total / 60)}m ${total % 60}s`;
49
+ }
50
+
51
+ /**
52
+ * 「正在跑」那一条:光看 `复盘 1` 这个数字不知道是谁、跑到哪、要不要杀,
53
+ * 所以把阶段 / 进程 / 最近输出 / 看日志的命令四行摊开(用户 2026-11-08:「复盘中我也想看到状态呢」)。
54
+ */
55
+ function runningItem(
56
+ run: RunSummary,
57
+ evolutionDir: string,
58
+ now: number,
59
+ alive: (pid: number) => boolean,
60
+ maxRuntimeMs: number,
61
+ ): PendingItem | null {
62
+ if (run.status !== "dispatched" || run.pid === null || !alive(run.pid)) return null;
63
+ const reviewing = run.supervisorPid !== null && alive(run.supervisorPid);
64
+ const logPath = path.join(evolutionDir, "runs", run.runId, CHILD_LOG);
65
+ let mtime: number | null = null;
66
+ try {
67
+ mtime = fs.statSync(logPath).mtimeMs; // 拿不到就是还没写过日志
68
+ } catch {
69
+ mtime = null;
70
+ }
71
+ const tail = readLogTail(logPath, 1);
72
+ const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
73
+ const progressLine =
74
+ mtime === null
75
+ ? "最近输出:(还没有输出)"
76
+ : `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
77
+ return {
78
+ runId: run.runId,
79
+ kind: "running",
80
+ session: sessionOfRun(run),
81
+ skill: run.proposal?.skill ?? "",
82
+ at: run.dispatchedAt,
83
+ detail: [
84
+ `阶段:${reviewing ? "评审中" : "复盘产出中"}`,
85
+ `进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
86
+ progressLine,
87
+ `日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
88
+ ].join("\n"),
89
+ };
90
+ }
91
+
92
+ /** 四类条目的展示优先级:正在跑最上头,然后卡死、待批、待入队 */
93
+ const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, reviewed: 3 };
94
+
95
+ export function buildPending(
96
+ items: readonly QueueItem[],
97
+ runs: readonly RunSummary[],
98
+ evolutionDir: string,
99
+ deps: WebStateDeps = {},
100
+ ): PendingItem[] {
101
+ const now = deps.now ?? Date.now();
102
+ const alive = deps.alive ?? defaultAlive;
103
+ const maxRuntimeMs = deps.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS;
104
+ const pending: PendingItem[] = [];
105
+ const byRun = sessionIndex(runs);
106
+ // ⓪ 正在跑的:有活 pid 就是「进行中」,先列出来(它不是等批也不是失败)
107
+ const running = new Set<string>();
108
+ for (const run of runs) {
109
+ const item = runningItem(run, evolutionDir, now, alive, maxRuntimeMs);
110
+ if (!item) continue;
111
+ running.add(run.runId);
112
+ pending.push(item);
113
+ }
114
+ // ① 队列里待人批的(approval 级;auto 级不需要人,出现 pending 只是还没轮到执行)
115
+ for (const item of items) {
116
+ if (item.status !== "pending" || item.level !== "approval") continue;
117
+ pending.push({
118
+ runId: item.runId,
119
+ kind: "approval",
120
+ session: sessionForRun(item.runId, byRun, evolutionDir),
121
+ skill: item.skill,
122
+ at: asNumber(item.createdAt),
123
+ detail: [
124
+ "层级:approval(必须人批,auto 不会替你执行)",
125
+ `目标:${item.targets.join("、") || "(未写 targets)"}`,
126
+ item.detail ? `备注:${item.detail}` : "",
127
+ `操作:去「待审批」页(?view=approvals)直接点批准 / 拒绝,也可以在这里回 TUI 敲 /skill-evolve approve ${item.runId}`,
128
+ ]
129
+ .filter((line) => line.length > 0)
130
+ .join("\n"),
131
+ });
132
+ }
133
+ // ② 已判定卡死的 run:它是「失败」而不是「等批」,单独一类,别混进待批数字里。
134
+ // 这里必须包含「判据命中、run.json 还没落盘」的那一批:顶部 summary.stale 用的是
135
+ // collectPanelState 的同一口径,少一类就会出现「头顶说卡死 5、列表只有 3 条」。
136
+ for (const run of runs) {
137
+ if (running.has(run.runId)) continue; // 同一个 run 不能既「进行中」又「卡死」
138
+ const persisted = run.status === "stale";
139
+ if (!persisted && !classifyStale(run, now).stale) continue;
140
+ const error = readRunError(path.join(evolutionDir, "runs", run.runId));
141
+ const tail = error?.childLogTail ?? [];
142
+ const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
143
+ pending.push({
144
+ runId: run.runId,
145
+ kind: "stale",
146
+ session: sessionOfRun(run),
147
+ skill: run.proposal?.skill ?? "",
148
+ at: error?.at ?? run.dispatchedAt,
149
+ detail: [
150
+ `原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
151
+ lastLine.length > 0 ? `日志末行:${lastLine}` : "日志末行:(子进程没留下任何输出)",
152
+ `排查:cat ${path.join(evolutionDir, "runs", run.runId, "run-error.json")}`,
153
+ "不会自动重跑:需要重新复盘就 /skill-evolve run",
154
+ ].join("\n"),
155
+ });
156
+ }
157
+ // ④ 子进程已产出结论、评审还没跑的:不是失败(子进程本来就该退出),属于「等下一轮接上」
158
+ for (const run of runs) {
159
+ if (run.status !== "analyzed" && run.status !== "proposed") continue;
160
+ pending.push({
161
+ runId: run.runId,
162
+ kind: "reviewed",
163
+ session: sessionOfRun(run),
164
+ skill: run.proposal?.skill ?? "",
165
+ at: run.dispatchedAt,
166
+ detail: [
167
+ `状态:已出结论(${run.status}),评审还没跑`,
168
+ "会自动接上:下次 settle 或 session_start 继续;想立即接上就 /skill-evolve run",
169
+ `证据:cat ${path.join(evolutionDir, "runs", run.runId, "01-findings.json")}`,
170
+ ].join("\n"),
171
+ });
172
+ }
173
+ // ③ 评审通过但还没进队列(入队发生在 settle / session_start,中间可能还没轮到)
174
+ const queued = new Set(items.map((item) => item.runId));
175
+ for (const run of runs) {
176
+ if (run.status !== "reviewed" || queued.has(run.runId)) continue;
177
+ pending.push({
178
+ runId: run.runId,
179
+ kind: "reviewed",
180
+ session: sessionOfRun(run),
181
+ skill: run.proposal?.skill ?? "",
182
+ at: run.dispatchedAt,
183
+ detail: [
184
+ `评审:${asText(run.review?.verdict, "(未写 verdict)")} · 建议动作 ${asText(run.review?.action, "无")}`,
185
+ "状态:评审已通过,尚未入队(settle / session_start 会自动补)",
186
+ `兜底:/skill-evolve approve ${run.runId}`,
187
+ ].join("\n"),
188
+ });
189
+ }
190
+ return pending.sort((a, b) => PENDING_RANK[a.kind] - PENDING_RANK[b.kind] || (b.at ?? 0) - (a.at ?? 0));
191
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * 页面骨架里的四块「管理性质」的东西:顶部控制条、列表筛选、分页、最近操作。
2
+ * 页面骨架里的四块「管理性质」的东西:顶部控制条、列表筛选、刷新提示、分页、最近操作。
3
3
  *
4
4
  * 都不依赖列表行,所以与 render-rows.ts 分开。
5
5
  *
6
- * 两条设计取舍:
6
+ * 三条设计取舍:
7
7
  * 1. **控制条只在服务端传了数据时才渲染**:渲染层不自己去读盘(否则 renderPage 会变成「读盘 + 渲染」两件事),
8
8
  * 也不在拿不到开关状态时猜一个出来 —— 顶部写「复盘:开」而实际是关的,比不写更糟;
9
9
  * 2. **筛选用 `<form method="get">` + hidden view**:零 JS,所以筛选结果能被拷成链接、
10
- * 能退回上一页、能在刷新后保持;翻页链接由 pageHref 带上全部筛选参数,不保留就等于点下一页即清空筛选。
10
+ * 能退回上一页、能在刷新后保持;翻页链接由 pageHref 带上全部筛选参数,不保留就等于点下一页即清空筛选;
11
+ * 3. **默认不自动刷新**:整页重载会丢掉 `<details>` 的展开态与滚动位置(用户 2026-11-08 报「点击展开过一会又被收起」),
12
+ * 所以默认只给一个「刷新」链接,`&live=1` 才是显式要求的 5 秒自动刷新。
11
13
  */
12
14
  import type { AuditEntry } from "./audit-log.ts";
13
15
  import type { ConfigFlag } from "../config.ts";
@@ -19,15 +21,14 @@ import {
19
21
  telemetryOffWhileEnabled,
20
22
  } from "../flags.ts";
21
23
  import type { WebView } from "./data.ts";
22
- import { RANGE_KEYS, RANGE_LABEL, STATUS_OPTIONS, pageHref, type ListFilter, type ViewPage } from "./filter.ts";
23
- import { escapeHtml, formatStamp } from "./text.ts";
24
+ import { RANGE_KEYS, RANGE_LABEL, STATUS_OPTIONS, type ListFilter, type ViewPage } from "./filter.ts";
25
+ import { listHref, type ListExtras } from "./list-query.ts";
26
+ import { escapeHtml, formatStamp, formatTimeSeconds } from "./text.ts";
24
27
  import type { WebControls } from "./activity.ts";
25
28
 
26
29
  export interface ControlBarOptions {
27
30
  /** 动作用户在哪一页点的,就回哪一页:技能管理页也要能开关采集(曝光数据就是它采的) */
28
31
  view: WebView | "skills";
29
- /** 现时(单测注入固定时钟):决定「最后活动」写成几分钟前 */
30
- now: number;
31
32
  csrf?: string;
32
33
  canWrite?: boolean;
33
34
  }
@@ -35,17 +36,17 @@ export interface ControlBarOptions {
35
36
  const MINUTE_MS = 60_000;
36
37
 
37
38
  /**
38
- * 「最后活动」的人话:只给粗粒度,不给秒 —— 精确到秒反而看不出差别,
39
+ * 「最后活动」的人话(摘要行尾部):只给粗粒度,不给秒 —— 精确到秒反而看不出差别,
39
40
  * 而「几分钟前 / 几小时前 / 几天前」正好是「这个插件还在动吗」需要的精度。
40
41
  */
41
42
  export function activityText(at: number | null, now: number): string {
42
- if (at === null) return "最后活动:还没有任何记录";
43
+ if (at === null) return "最后活动 还没有任何记录";
43
44
  const minutes = Math.floor(Math.max(0, now - at) / MINUTE_MS);
44
- if (minutes < 1) return "最后活动:刚刚";
45
- if (minutes < 60) return `最后活动:${minutes} 分钟前`;
45
+ if (minutes < 1) return "最后活动 刚刚";
46
+ if (minutes < 60) return `最后活动 ${minutes} 分钟前`;
46
47
  const hours = Math.floor(minutes / 60);
47
- if (hours < 24) return `最后活动:${hours} 小时前`;
48
- return `最后活动:${Math.floor(hours / 24)} 天前`;
48
+ if (hours < 24) return `最后活动 ${hours} 小时前`;
49
+ return `最后活动 ${Math.floor(hours / 24)} 天前`;
49
50
  }
50
51
 
51
52
  /** 控制条上的「立即复盘」按钮;只读面板不渲染任何表单 */
@@ -59,15 +60,16 @@ function runForm(ctx: ControlBarOptions): string {
59
60
  }
60
61
 
61
62
  /**
62
- * 一个独立开关:状态 + 后果说明 + 「点下去会发生什么」的按钮。
63
+ * 一个独立开关:一个 chip = 状态 + 「点下去会发生什么」的按钮,代价说明塞进 `title`。
63
64
  *
64
65
  * 四个开关各自 post 到 `/toggle?field=`(带上隐藏的 view,动作完回同一页),**不互相联动**:
65
66
  * 想采集但不花钱、想评审但不自动采纳,都是常见中间态;要批量切只有命令 `on|off`。
66
67
  * 按钮文案写动作不写状态 —— 状态已经在左边那一段里,按钮再写一遍会让人把「当前值」看成「点了会变的值」。
68
+ * 代价说明必须留着(「花 token」「改 .pi/skills/」是点之前就该知道的),只是不该占一整行把开关挤成两排。
67
69
  */
68
70
  function flagSwitch(field: ConfigFlag, on: boolean, ctx: ControlBarOptions): string {
69
71
  const state = `<span class="state ${on ? "on" : "off"}">${FLAG_LABEL[field]}:${on ? "开" : "关"}</span>`;
70
- const effect = `<span class="hint">${FLAG_EFFECT[field]}</span>`;
72
+ const title = ` title="${escapeHtml(FLAG_EFFECT[field])}"`;
71
73
  const writable = ctx.canWrite === true && (ctx.csrf ?? "").length > 0;
72
74
  const form = writable
73
75
  ? `<form method="post" action="/toggle?field=${field}">
@@ -76,11 +78,11 @@ function flagSwitch(field: ConfigFlag, on: boolean, ctx: ControlBarOptions): str
76
78
  <button type="submit">${on ? "关闭" : "开启"}${FLAG_LABEL[field]}</button>
77
79
  </form>`
78
80
  : "";
79
- return `<span class="sw">${state}${effect}${form}</span>`;
81
+ return `<span class="sw"${title}>${state}${form}</span>`;
80
82
  }
81
83
 
82
84
  /**
83
- * 顶部控制条:四个独立开关(采集 / 复盘 / 评审 / 采纳)+ 立即复盘 + 最后活动。
85
+ * 顶部控制条:四个独立开关(采集 / 复盘 / 评审 / 采纳)+ 立即复盘,挤在同一行。
84
86
  *
85
87
  * 四个开关不许合成一个「开启全部」:采集只写本地文件,复盘与评审才花 token,
86
88
  * 采纳直接改仓库文件 —— 想采集样本但不想花钱、想评审但不想自动落盘,都是常见的中间状态。
@@ -100,8 +102,7 @@ export function renderControlBar(controls: WebControls, ctx: ControlBarOptions):
100
102
  const switches = FLAG_FIELDS.map((field) => flagSwitch(field, controls[field], ctx)).join("\n");
101
103
  return `<div class="ctl">
102
104
  ${switches}
103
- ${run}<span class="act">${escapeHtml(activityText(controls.lastActivityAt, ctx.now))}</span>
104
- </div>
105
+ ${run}</div>
105
106
  ${warn}${backlog}`;
106
107
  }
107
108
 
@@ -120,32 +121,57 @@ function rangeSelect(current: string): string {
120
121
  ).join("");
121
122
  }
122
123
 
123
- /** 筛选表单:hidden view 保证 GET 提交后还在同一页;「重置」链回不带筛选的本页 */
124
- export function renderFilterForm(view: WebView, filter: ListFilter): string {
124
+ /**
125
+ * 筛选表单:hidden view 保证 GET 提交后还在同一页;「重置」链回不带筛选的本页。
126
+ * `open` / `live` 也一并带上:筛选不该顺手把「展开分组」「自动刷新」这两个开关清掉。
127
+ */
128
+ export function renderFilterForm(view: WebView, filter: ListFilter, extras: ListExtras): string {
129
+ const keep = extraInputs(extras);
125
130
  return `<form class="filter" method="get">
126
131
  <input type="hidden" name="view" value="${view}">
127
- <label>技能 <input type="text" name="skill" value="${escapeHtml(filter.skill)}" placeholder="全部技能" size="16"></label>
132
+ ${keep}<label>技能 <input type="text" name="skill" value="${escapeHtml(filter.skill)}" placeholder="全部技能" size="16"></label>
128
133
  <label>状态 <select name="status">${statusSelect(filter.status)}</select></label>
129
134
  <label>时间 <select name="range">${rangeSelect(filter.range)}</select></label>
130
135
  <button type="submit">筛选</button>
131
- <a class="btn" href="/?view=${view}">重置</a>
136
+ <a class="btn" href="${escapeHtml(listHref(view, filter, 1, extras))}">重置</a>
132
137
  </form>`;
133
138
  }
134
139
 
140
+ /** 把非筛选的显式开关写成 hidden:表单提交只带这几个,不带的字段自然回落默认值 */
141
+ function extraInputs(extras: ListExtras): string {
142
+ const parts: string[] = [];
143
+ if (extras.live) parts.push(`<input type="hidden" name="live" value="1">`);
144
+ if (extras.open === "none") parts.push(`<input type="hidden" name="open" value="none">`);
145
+ return parts.length === 0 ? "" : `${parts.join("")}\n`;
146
+ }
147
+
148
+ /**
149
+ * 「更新于 21:57:03 · 刷新」:默认的手动刷新入口。
150
+ * 时间精确到秒:这是「我看到的就是这一刻的数据」的凭证,只到分钟会让人反复点刷新。
151
+ */
152
+ export function renderUpdatedAt(now: number, href: string): string {
153
+ return `更新于 ${formatTimeSeconds(now)} · <a href="${escapeHtml(href)}">刷新</a>`;
154
+ }
155
+
156
+ /**
157
+ * `&live=1` 时的头部提示:明说「展开态会重置」。
158
+ * 整页重载必然丢展开态与滚动位置,与其让人以为面板在帮他收起,不如提前讲清代价并给一个关掉的链接。
159
+ */
160
+ export function renderLiveNote(offHref: string): string {
161
+ return `<p class="warn">自动刷新中(每 5 秒整页重载,展开态会重置)· <a href="${escapeHtml(offHref)}">关掉</a></p>`;
162
+ }
163
+
135
164
  /**
136
- * 分页条:`第 X / Y 页,共 N 条` 永远显示(空列表也显示「第 1 / 1 页,共 0 条」——
165
+ * 分页条:`第 X / Y 页 · 共 N 条 · 上一页 / 下一页` 永远显示(空列表也显示「第 1 / 1 页 · 共 0 条」——
137
166
  * 页脚消失了人只会以为筛选坏了),只有到底时才把箭头换成灰字。
138
167
  */
139
- export function renderPagination(view: WebView, page: ViewPage, filter: ListFilter): string {
168
+ export function renderPagination(view: WebView, page: ViewPage, filter: ListFilter, extras: ListExtras): string {
169
+ const href = (target: number) => escapeHtml(listHref(view, filter, target, extras));
140
170
  const prev =
141
- page.page > 1
142
- ? `<a href="${escapeHtml(pageHref(view, filter, page.page - 1))}">← 上一页</a>`
143
- : `<span class="off">← 上一页</span>`;
171
+ page.page > 1 ? `<a href="${href(page.page - 1)}">← 上一页</a>` : `<span class="off">← 上一页</span>`;
144
172
  const next =
145
- page.page < page.pages
146
- ? `<a href="${escapeHtml(pageHref(view, filter, page.page + 1))}">下一页 →</a>`
147
- : `<span class="off">下一页 →</span>`;
148
- return `<p class="page">${prev} <span class="count">第 ${page.page} / ${page.pages} 页,共 ${page.total} 条</span> ${next}</p>`;
173
+ page.page < page.pages ? `<a href="${href(page.page + 1)}">下一页 →</a>` : `<span class="off">下一页 →</span>`;
174
+ return `<p class="page">${prev} <span class="count">第 ${page.page} / ${page.pages} 页 · 共 ${page.total} 条</span> ${next}</p>`;
149
175
  }
150
176
 
151
177
  /** 「最近操作」:时间 + 动作 + runId + 结果;结果里的 error 前缀单独染色,扫一眼就知道有没有出事 */