pi-skill-evolve 0.1.5 → 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.
- package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +177 -235
- package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
- package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
- package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
- package/extensions/skill-evolve/lib/web/render.ts +116 -54
- package/extensions/skill-evolve/lib/web/server.ts +9 -1
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -106
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +11 -5
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +75 -87
- package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
- package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
- package/extensions/skill-evolve/test/web-render.test.ts +57 -41
- package/extensions/skill-evolve/test/web-server.test.ts +4 -1
- package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表页的三个**显式开关** query:`live`(自动刷新)、`open`(分组是否展开)、`only`(会话页只看对话)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么与 filter.ts 分开:筛选决定「哪些条目出现」,这三个开关决定「同一批条目怎么呈现/何时重载」。
|
|
5
|
+
* 混进 `ListFilter` 会让 `paginate` / `buildViewPage` 每处都得带着两个不相干的字段。
|
|
6
|
+
*
|
|
7
|
+
* 为什么全走 URL 而不是 JS:面板零 JS。用户点过「收起分组」就该在这次刷新后仍然收着 ——
|
|
8
|
+
* 只有 URL 能记住这件事(服务端读它并显式回写链接),localStorage 之类都不在这个面板的可用手段里。
|
|
9
|
+
*/
|
|
10
|
+
import { pageHref, type ListFilter } from "./filter.ts";
|
|
11
|
+
import type { WebView } from "./data.ts";
|
|
12
|
+
|
|
13
|
+
export interface ListExtras {
|
|
14
|
+
/** 5 秒整页自动刷新:只在待处理页生效(其它页数据不会自己变,重载只会丢展开态) */
|
|
15
|
+
live: boolean;
|
|
16
|
+
/** 分组默认展开;`none` 是用户点了「全部收起」 */
|
|
17
|
+
open: "all" | "none";
|
|
18
|
+
/** 会话页只看对话(user 消息 + 助手纯文本),工具调用压成一行 */
|
|
19
|
+
only: "user" | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_EXTRAS: ListExtras = { live: false, open: "all", only: null };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析三个开关。**按页面白名单放行**:`live=1` 在已采纳页没有意义,
|
|
26
|
+
* 照单全收会让「?view=done&live=1」也被 5 秒重载一次,用户会以为面板抽风。
|
|
27
|
+
*/
|
|
28
|
+
export function parseListExtras(params: URLSearchParams, view: WebView | "session"): ListExtras {
|
|
29
|
+
return {
|
|
30
|
+
live: view === "pending" && params.get("live") === "1",
|
|
31
|
+
open: params.get("open") === "none" ? "none" : "all",
|
|
32
|
+
only: view === "session" && params.get("only") === "user" ? "user" : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 翻页 / 刷新 / 收起分组共用的链接拼装:筛选参数由 pageHref 负责,这里只追加三个开关 */
|
|
37
|
+
export function listHref(view: WebView, filter: ListFilter, page: number, extras: ListExtras): string {
|
|
38
|
+
const base = pageHref(view, filter, page);
|
|
39
|
+
const params = new URLSearchParams(base.slice(base.indexOf("?") + 1));
|
|
40
|
+
if (extras.live) params.set("live", "1");
|
|
41
|
+
if (extras.open === "none") params.set("open", "none");
|
|
42
|
+
return `/?${params.toString()}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown-lite:会话正文里那一小撮「模型天天写、原样输出却很难看」的语法。
|
|
3
|
+
*
|
|
4
|
+
* 为什么只做这几个(用户 2026-11-08:「Markdown 原样输出满屏 `**加粗**`」):
|
|
5
|
+
* pi 的助手回复几乎每段都带 `**粗体**`、`` `路径` ``、`- ` 列表、``` 代码块、`[文字](url)`,
|
|
6
|
+
* 直接 `<pre>` 输出等于把符号也念给人听。但面板不需要完整 Markdown 实现
|
|
7
|
+
* (表格、脚注、嵌套列表一律不做),行数少、行为可预测比「支持得多」重要。
|
|
8
|
+
*
|
|
9
|
+
* **XSS 纪律**:先 `escapeHtml` 再替换语法,替换只插自己写的标签,用户文本永远进不了标签内部;
|
|
10
|
+
* `[文字](url)` 只保留文字、整段丢弃 URL —— 否则 `[x](javascript:alert(1))` 就是一个可点的洞。
|
|
11
|
+
*/
|
|
12
|
+
import { escapeHtml } from "./text.ts";
|
|
13
|
+
|
|
14
|
+
/** 围栏代码块:开口行可带语言名(不渲染高亮,只要别把它当正文) */
|
|
15
|
+
const FENCE = /```[^\n]*\n([\s\S]*?)```/g;
|
|
16
|
+
|
|
17
|
+
/** 占位符用的控制字符:正文转义后不可能出现,所以不会被内容伪造 */
|
|
18
|
+
const SLOT = "\u0000";
|
|
19
|
+
|
|
20
|
+
/** 行内语法:先抠出代码块占位,再处理粗体 / 行内代码 / 链接(顺序不能反,否则代码块里的 `*` 会被当粗体) */
|
|
21
|
+
function inline(escaped: string, blocks: string[]): string {
|
|
22
|
+
const withSlots = escaped.replace(FENCE, (_all, code: string) => {
|
|
23
|
+
blocks.push(`<pre class="code"><code>${code.replace(/\n$/, "")}</code></pre>`);
|
|
24
|
+
return `${SLOT}${blocks.length - 1}${SLOT}`;
|
|
25
|
+
});
|
|
26
|
+
return withSlots
|
|
27
|
+
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
|
28
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
|
29
|
+
.replace(/\[([^\]\n]+)\]\((?:[^()\s]|\([^()\s]*\))*\)/g, "$1");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 把段落里残留的代码块占位换回真区块(代码块夹在句子中间时不会独占一段) */
|
|
33
|
+
function restore(text: string, blocks: string[]): string {
|
|
34
|
+
return text.replace(new RegExp(`${SLOT}(\\d+)${SLOT}`, "g"), (_all, index: string) => blocks[Number(index)] ?? "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 文字与代码块混在一段:按占位符切开,文字段各自包 `<p>`。
|
|
39
|
+
* 不能直接把 `<pre>` 塞进 `<p>`(浏览器会把 `<p>` 提前闭合,后面的文字被踢出正文流)。
|
|
40
|
+
*/
|
|
41
|
+
function mixed(lines: string[], blocks: string[]): string {
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
let buffer: string[] = [];
|
|
44
|
+
const flush = (): void => {
|
|
45
|
+
const text = buffer.join("\n").trim();
|
|
46
|
+
if (text.length > 0) out.push(`<p>${text.replace(/\n/g, "<br>")}</p>`);
|
|
47
|
+
buffer = [];
|
|
48
|
+
};
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
line.split(new RegExp(`${SLOT}(\\d+)${SLOT}`)).forEach((part, index) => {
|
|
51
|
+
if (index % 2 === 1) {
|
|
52
|
+
flush();
|
|
53
|
+
out.push(blocks[Number(part)] ?? "");
|
|
54
|
+
} else if (part.length > 0) {
|
|
55
|
+
buffer.push(part);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
flush();
|
|
60
|
+
return out.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 一个段落:代码块占位 / 标题 / 列表 / 普通段落(普通段落里的换行保留成 `<br>`) */
|
|
64
|
+
function block(text: string, blocks: string[]): string {
|
|
65
|
+
const slot = new RegExp(`^${SLOT}(\\d+)${SLOT}$`).exec(text);
|
|
66
|
+
if (slot !== null) return blocks[Number(slot[1])] ?? "";
|
|
67
|
+
const heading = /^(#{1,2}) (.*)$/.exec(text);
|
|
68
|
+
if (heading !== null) {
|
|
69
|
+
const level = heading[1] === "#" ? 3 : 4;
|
|
70
|
+
return `<h${level} class="md-h">${restore(heading[2], blocks)}</h${level}>`;
|
|
71
|
+
}
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
if (lines.every((line) => line.startsWith("- "))) {
|
|
74
|
+
return `<ul class="md-ul">${lines.map((line) => `<li>${restore(line.slice(2), blocks)}</li>`).join("")}</ul>`;
|
|
75
|
+
}
|
|
76
|
+
if (text.includes(SLOT)) return mixed(lines, blocks);
|
|
77
|
+
return `<p>${lines.join("<br>")}</p>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 正文 → HTML。空文本给空串(调用方据此判断「这条没有正文,别画空块」),
|
|
82
|
+
* 其余按空行分段,每段单独判定类型。
|
|
83
|
+
*/
|
|
84
|
+
export function renderMarkdownLite(raw: string): string {
|
|
85
|
+
if (raw.trim().length === 0) return "";
|
|
86
|
+
const blocks: string[] = [];
|
|
87
|
+
const text = inline(escapeHtml(raw), blocks);
|
|
88
|
+
return text
|
|
89
|
+
.split(/\n{2,}/)
|
|
90
|
+
.map((part) => part.replace(/^\n+|\n+$/g, ""))
|
|
91
|
+
.filter((part) => part.length > 0)
|
|
92
|
+
.map((part) => block(part, blocks))
|
|
93
|
+
.filter((part) => part.length > 0)
|
|
94
|
+
.join("\n");
|
|
95
|
+
}
|
|
@@ -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,
|
|
23
|
-
import {
|
|
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
|
|
45
|
+
if (minutes < 1) return "最后活动 刚刚";
|
|
46
|
+
if (minutes < 60) return `最后活动 ${minutes} 分钟前`;
|
|
46
47
|
const hours = Math.floor(minutes / 60);
|
|
47
|
-
if (hours < 24) return
|
|
48
|
-
return
|
|
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
|
|
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}${
|
|
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}
|
|
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
|
-
/**
|
|
124
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
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 前缀单独染色,扫一眼就知道有没有出事 */
|