dsh-workplan 1.5.0 → 1.5.2
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/CHANGELOG.md +173 -0
- package/client.js +153 -33
- package/lib/host.js +1 -1
- package/lib/plan.js +112 -8
- package/lib/runtime.js +83 -12
- package/package.json +1 -1
- package/src/client.ts +151 -34
- package/src/host.ts +1 -1
- package/src/plan.ts +110 -8
- package/src/runtime.ts +83 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
+
## 1.5.2 — 2026-10-29
|
|
2
|
+
|
|
3
|
+
**「完成语义」统一版**。用户截图圈出一张 `14/14` 全部做完、环形进度 `100%` 的计划卡,
|
|
4
|
+
右侧状态胶囊却写「正在收尾」,而且这张卡仍留在「进行中」组。本次把**进度**与**状态**
|
|
5
|
+
收敛到同一个事实源,并把状态矩阵里同一根因的 8 类矛盾一并修掉。
|
|
6
|
+
|
|
7
|
+
### 事故与根因
|
|
8
|
+
|
|
9
|
+
截图会话为 `session-c3852c31-f6cd-4fc5-8cff-d8a3932e9feb`。事件回放显示:
|
|
10
|
+
|
|
11
|
+
1. 最后一次派发在预算用尽时结束,结果消息落盘为 `12/14 done、phase: paused`;
|
|
12
|
+
2. 之后主 Agent 直接调用 `dsh_workplan_update`,把剩余 `T13`、`T14` 标为 done
|
|
13
|
+
(会话里两次工具调用,`seq 263` / `seq 269`),**没有再发生任何派发轮次**;
|
|
14
|
+
3. `updateStatus` 用 `planPhase(statuses)` 重算 phase,而 `planPhase` 以
|
|
15
|
+
「结果消息是否已落盘(`resultSeen`)」为终态闸门 → 全部完成后仍返回 `integrating`;
|
|
16
|
+
4. 结果消息只在派发轮次末尾写入,走 update 工具的路径**永远不会**写它,
|
|
17
|
+
于是 phase 永久停在 `integrating`,`stats.progress` 已经是 `100`;
|
|
18
|
+
5. `serialisePlan` 又优先信任内存里的 `record.phase`,而回放 `reconstructFromSession`
|
|
19
|
+
用 `resultSeen = true` 重算出 `done` —— 同一条记录「刷新前正在收尾、刷新后已完成」;
|
|
20
|
+
6. 客户端还有三套独立的「是否完成」判断:环形进度看 `percent >= 100`、
|
|
21
|
+
「都做完了」看 `done === total`、状态胶囊看 `phase`,三者可以各说各话。
|
|
22
|
+
|
|
23
|
+
因此这不是文案问题,而是**完成事实有两个事实源**(计数 vs. 副作用闸门)。
|
|
24
|
+
|
|
25
|
+
### 改动
|
|
26
|
+
|
|
27
|
+
- **`src/plan.ts`**
|
|
28
|
+
- `planPhase()`:所有子任务 `done` 时**恒返回 `done`**,不再看 `resultSeen`
|
|
29
|
+
(参数保留以兼容既有调用,标注为历史遗留);`integrating` 仅作为旧载荷的
|
|
30
|
+
兼容取值存在。
|
|
31
|
+
- 新增 `reconcilePhase(phase, statuses)`:终态只由计数决定;`done` / `integrating`
|
|
32
|
+
这类「由计数派生」的旧值一律重算;`paused` / `cancelled` / `blocked` / `degraded`
|
|
33
|
+
保留各自的事实来源。
|
|
34
|
+
- `deriveAggregates()`:未全部完成时总进度**封顶 99**(`Math.min(99, raw)`),
|
|
35
|
+
杜绝「11 done + 1 blocked@95 → 总体进度 100」。
|
|
36
|
+
- `applyStatus()`:`in_progress` 的 `progress` 夹紧到 `99`,「正在做」不允许自称 100%。
|
|
37
|
+
- `aggregateInvariantViolations(snapshot, phase?)`:新增
|
|
38
|
+
**I8**(`done === total` 必须 `phase === 'done'`,双向)、
|
|
39
|
+
**I9**(全部完成必须 100%,未全部完成不得 >= 100)、
|
|
40
|
+
**I10**(`in_progress` 不得 `progress >= 100`)。
|
|
41
|
+
- **`src/runtime.ts`**
|
|
42
|
+
- `serialisePlan()`:`phase` 改为 `reconcilePhase(record.phase ?? 派生值, statuses)`;
|
|
43
|
+
载荷在存在不变量违背时附带 `violations` 数组,便于定位而不是静默显示。
|
|
44
|
+
- `updateStatus()`:改为 `reconcilePhase(record.phase, record.statuses)`
|
|
45
|
+
—— 截图矛盾的直接入口。
|
|
46
|
+
- 派发尾部:终态晋升不再依赖「结果消息写入」这一副作用,同样走 `reconcilePhase`。
|
|
47
|
+
- 回放 `reconstructFromSession()`:paused/cancelled 覆盖之后统一 `reconcilePhase`,
|
|
48
|
+
保证**刷新前后同源**。
|
|
49
|
+
- `cancelPlan()`:计划已全部完成时改为 no-op(返回 `alreadyDone: true`),
|
|
50
|
+
消除「100% + 已取消」。
|
|
51
|
+
- 预算到点分支:若此刻已经全部完成,记 `done` 而不是 `paused`(防御性,正常循环下不可达)。
|
|
52
|
+
- **`src/client.ts`**(自包含 bundle,不能 import `plan.ts`,故在客户端复刻同一语义)
|
|
53
|
+
- 三个唯一算式:`planPercent(done, total)`、`planPhaseOf(rawPhase, counts)`、
|
|
54
|
+
`planWeightedPercent(stats, counts)`;卡片环、徽章、分组、分组环、阶段条、
|
|
55
|
+
会话内联卡、`rank`、`liveBars` 全部改走它们。
|
|
56
|
+
- 新增文案:`helpWaitActive`(等这一步做完,再继续下一项)、
|
|
57
|
+
`helpNoPlan`(还没有安排任何工作)、`nowRemaining`(还剩 {n} 项)。
|
|
58
|
+
- **测试**
|
|
59
|
+
- 新增 `tests/phase-percent-invariant.test.ts`(13 例):终态语义、计数封顶、
|
|
60
|
+
I8/I9 双向、update 补完最后一项、空更新不回退、取消已完成计划、
|
|
61
|
+
客户端渲染旧载荷。
|
|
62
|
+
- 新增 `tests/completion-semantics.test.ts`(8 例):`in_progress@100` 夹紧、
|
|
63
|
+
I10、单任务执行中不再宣布完成、部分完成「还剩 N 项」、0/0 降级不说完成、
|
|
64
|
+
阶段条只按 done 计宽度、阶段卡与「现在」同口径、0% 未开始不再标 active。
|
|
65
|
+
- 更新 `tests/plan.test.ts`、`tests/workplan-budget-pause.test.ts` 里把
|
|
66
|
+
「全部完成后停在进行中」当期望值的断言;`tests/derived-counts.test.ts`
|
|
67
|
+
的 blocked 用例期望改为 99。
|
|
68
|
+
- **版本**:`1.5.1 → 1.5.2`(patch,修正完成语义,不新增对外接口)。
|
|
69
|
+
|
|
70
|
+
### 同类矛盾(来自 `docs/state-progress-matrix.md`)
|
|
71
|
+
|
|
72
|
+
| 编号 | 现象 | 本次 |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| C1 | 100% + 「正在收尾」+ 留在「进行中」 | 已修 |
|
|
75
|
+
| C2 | 执行中却写「没有下一步,这件事已完成」 | 已修 |
|
|
76
|
+
| C5 | 部分完成却写「还没开始」 | 已修 |
|
|
77
|
+
| C7 | 阶段条把 blocked 算作已触达(环 50%、条 100%) | 已修 |
|
|
78
|
+
| C8 | 允许 `in_progress + 100%` | 已修 |
|
|
79
|
+
| C9 | 阶段行说「正在做」、阶段卡说「还没开始」 | 已修 |
|
|
80
|
+
| C11 | 0/0 降级计划也说「这件事已完成」 | 已修 |
|
|
81
|
+
| C12 | 同屏两个互斥的「现在」 | 已修 |
|
|
82
|
+
| C13 | 0% 未开始的卡标 `data-state="active"` | 已修 |
|
|
83
|
+
| C3 | 已取消的卡在前端被过滤,用户看不到(与 `dsh_workplan_cancel` 描述文案不一致) | 未改:既有设计,`tests/client-render.test.ts` 明确断言不渲染;记录待与工具描述统一 |
|
|
84
|
+
| C10 | stale pending 被序列化成 cancelled,而回放返回 `pending + stale:true` | 未改:记录为下一步 |
|
|
85
|
+
| C14 | `planning` 是死枚举 | 未改:记录为清理项 |
|
|
86
|
+
|
|
87
|
+
### 验证
|
|
88
|
+
|
|
89
|
+
- `npm run build` → exit 0;`npm run check`(`tsc --noEmit`)→ exit 0。
|
|
90
|
+
- `npm test` → **120/120 通过**,0 fail(原 99 + 新增 13 + 8)。
|
|
91
|
+
- `node scripts/repro-phase-percent-integrating.mjs --expect-fixed` → 7/7 PASS,
|
|
92
|
+
`payload.phase = done`、`stats = { total: 2, done: 2, progress: 100 }`、
|
|
93
|
+
徽章「已完成」、`live = done`、`replay = done`、无 `violations`。
|
|
94
|
+
(同一脚本不带参数运行现在会 3 条 FAIL —— 这是旧 bug 已不存在、修复生效的证据。)
|
|
95
|
+
- 版本一致性 7 处:`package.json`、`package-lock.json` 两处、`src/host.ts`、
|
|
96
|
+
`lib/host.js`、`dist/host.js`、本文件顶部,全部 `1.5.2`。
|
|
97
|
+
- 本次**未重启 DSH 服务**:`lib/*.js`(host 侧)在 DSH 进程下次启动时才加载;
|
|
98
|
+
`client.js` 随页面刷新生效。
|
|
99
|
+
|
|
100
|
+
### 发布
|
|
101
|
+
|
|
102
|
+
- **未执行**。`git tag v1.5.2`、`npm publish` 属对外发布,需用户明确授权后再做。
|
|
103
|
+
|
|
104
|
+
### 回滚
|
|
105
|
+
|
|
106
|
+
- 升级前四个文件的副本:`.backups/pre-1.5.2-20261029-125724/`
|
|
107
|
+
(`package.json`、`package-lock.json`、`CHANGELOG.md`、`src/host.ts`)。
|
|
108
|
+
- 回滚后必须重新 `npm run build`;运行中的 DSH 进程不会自动重载 `lib/host.js`。
|
|
109
|
+
|
|
110
|
+
## 1.5.1 — 2026-10-28
|
|
111
|
+
|
|
112
|
+
**「先看这三句」与「分步进度」位置对换的补丁版**。用户以截图圈出展开态计划卡里的这两个板块,
|
|
113
|
+
要求把它们的布局位置互换。本次只调整渲染顺序,**数据、接口、样式与既有测试断言零改动**。
|
|
114
|
+
|
|
115
|
+
### 改动
|
|
116
|
+
|
|
117
|
+
- **展开态面板顺序对换**(`src/client.ts`):原来的「先看这三句(`Guide`)→ 降级横幅 →
|
|
118
|
+
分步进度(`PlanOverview`)」改为「分步进度(`PlanOverview`)→ 降级横幅 → 先看这三句(`Guide`)」。
|
|
119
|
+
两个面板组件本身及其内部逻辑不变;`PlanOverview` 仍然只在计划卡展开时(`open === true`)挂载,
|
|
120
|
+
折叠态依旧不挂载。
|
|
121
|
+
- **版本**:`1.5.0 → 1.5.1`(patch,仅布局顺序对换,不新增能力、不改对外载荷);`package.json`、
|
|
122
|
+
`package-lock.json` 两处、`src/host.ts` 的 `meta.version` 同步;`lib/host.js`、`dist/host.js`、
|
|
123
|
+
`client.js` 由 `npm run build` 重新生成。
|
|
124
|
+
|
|
125
|
+
### 验证
|
|
126
|
+
|
|
127
|
+
- `npm run build` → exit 0;`npm run check` → exit 0。
|
|
128
|
+
- `npm test` → **99/99 通过**(0 fail)。
|
|
129
|
+
- 7 处版本一致性检查 → `[PASS] 7 处版本均为 1.5.1`(`package.json`、`package-lock.json` 两处、
|
|
130
|
+
`src/host.ts`、`lib/host.js`、`dist/host.js`、`CHANGELOG.md` 顶部)。
|
|
131
|
+
- 提交 `fbe467b`(2026-10-28 22:27:53 +08:00)与 annotated tag `v1.5.1`
|
|
132
|
+
(taggerdate 2026-10-28 22:28:51 +08:00)已就绪并**已对外发布**(见下「发布」)。
|
|
133
|
+
- 本次**未重启 DSH 服务**;运行实例里的界面换序靠刷新页面触发(客户端 bundle 热重载)。
|
|
134
|
+
|
|
135
|
+
### 发布
|
|
136
|
+
|
|
137
|
+
- 两个渠道、同一份制品:
|
|
138
|
+
- git:`https://github.com/abbaababa/dsh-workplan.git` 的 annotated tag `v1.5.1` → `fbe467b`;
|
|
139
|
+
- npm:公共 registry `https://registry.npmjs.org/dsh-workplan` 的 `1.5.1`(`dist-tags.latest`)。
|
|
140
|
+
- **状态(2026-10-28 22:46–22:50 +08:00 实测,发布已完成)**:
|
|
141
|
+
- `git push origin refs/heads/main` → `db74d44..fbe467b main -> main`(快进,exit 0);
|
|
142
|
+
- `git push origin refs/tags/v1.5.1` → `* [new tag] v1.5.1 -> v1.5.1`(exit 0);
|
|
143
|
+
- 远端复核(`git ls-remote`,独立网络查询):`refs/heads/main` = `fbe467b4a632…`,
|
|
144
|
+
`refs/tags/v1.5.1` = `120dc2cb…` → `^{}` = `fbe467b4a632…`,本地 `ahead/behind` = `0/0`;
|
|
145
|
+
- `npm publish --ignore-scripts`(在 `git worktree add --detach … v1.5.1` 的干净快照上执行)
|
|
146
|
+
→ `+ dsh-workplan@1.5.1`(exit 0);
|
|
147
|
+
- `https://registry.npmjs.org/dsh-workplan/1.5.1` → **HTTP 200**,`time['1.5.1']` =
|
|
148
|
+
`2026-10-28T14:49:36.697Z`(22:49:36 +08:00),`dist-tags.latest` = `1.5.1`;
|
|
149
|
+
- 消费者视角:空白目录 `npm i dsh-workplan@1.5.1 --prefer-online` → 装到 1.5.1
|
|
150
|
+
(22 个文件,`client.js` 138 195 字节,`lib/host.js` 内 `version: '1.5.1'`)。
|
|
151
|
+
- **已发布制品的校验值**(registry 实测,与本地 `.tgz` 逐字节一致):
|
|
152
|
+
- npm `shasum`(SHA-1):`a6f35bd1b3d2630e3a2cf9e9555b58e51b20eaba`
|
|
153
|
+
- npm `integrity`(SHA-512 SRI):`sha512-AmpYMx55Y0ea2tAc14C4V7t/Pxrho+9nZXZqxz7JtdPYvFRyxS+wzEIUCfATRgq5cogT22lTSf0pEwUYOlgdqw==`
|
|
154
|
+
- 说明:同一份内容在原工作树打包时 `shasum` 为 `ecb39f0…`、在干净 `git worktree` 快照上为
|
|
155
|
+
`a6f35bd1…`,差异只来自 tar 记录的 mtime;**22 个文件的 blob 哈希与 tag `v1.5.1` 逐一相同**
|
|
156
|
+
(`checked 22 / mismatch 0`,另 44 个未随包发布的仓库文件按 `files` 白名单正确排除)。
|
|
157
|
+
实际发布的是干净快照这一份,故以上两行为准。
|
|
158
|
+
- 发布顺序:先分支、后标签、再 npm,避免出现「远端有 tag 而 npm 没有」的半发布态。
|
|
159
|
+
- registry 有约 60–90 秒传播延迟(22:48:58、22:49:20 直连仍 404,22:49:41 转 200);
|
|
160
|
+
该窗口内 `npm i` 需加 `--prefer-online` 绕过本地元数据缓存。
|
|
161
|
+
- 命令原文、失败分支、发布后验证与回滚:见 `docs/release-record-1.5.1-20261028.md`。
|
|
162
|
+
|
|
163
|
+
### 回滚
|
|
164
|
+
|
|
165
|
+
- 升级前四个文件的副本:`.backups/pre-1.5.1-20261028-221343/`
|
|
166
|
+
(`package.json`、`package-lock.json`、`CHANGELOG.md`、`src/host.ts`)。
|
|
167
|
+
- 回滚后必须重新 `npm run build`;运行中的进程不会自动重载 `lib/host.js`。
|
|
168
|
+
- 已推送(远端已有 `fbe467b` 与 `v1.5.1`):`git push origin :refs/tags/v1.5.1` 删远端标签,
|
|
169
|
+
`git revert fbe467b` 回退提交(不要 `push --force` 改远端 `main` 历史)。
|
|
170
|
+
- 已发布到 npm:先用 `npm deprecate dsh-workplan@1.5.1 "回滚说明"` 引导用户;确需撤销时
|
|
171
|
+
`npm unpublish dsh-workplan@1.5.1 --force`——受 npm 官方 unpublish 政策约束(可撤销时间窗口、
|
|
172
|
+
「有包依赖时不可撤销」),执行前核对 `npm unpublish --help` 与 npm 官方文档。
|
|
173
|
+
|
|
1
174
|
## 1.5.0 — 2026-10-28
|
|
2
175
|
|
|
3
176
|
**「进度必须一眼看得见」的图表化版本**。1.4.3 把数字收敛成单一来源,但计划卡折叠后仍然只有一行
|
package/client.js
CHANGED
|
@@ -43,6 +43,9 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
"helpNobody": "还没开始",
|
|
44
44
|
"helpDone": "都做完了",
|
|
45
45
|
"helpNextDone": "没有下一步,这件事已完成",
|
|
46
|
+
"helpWaitActive": "等这一步做完,再继续下一项",
|
|
47
|
+
"helpNoPlan": "还没有安排任何工作",
|
|
48
|
+
"nowRemaining": "还剩 {n} 项",
|
|
46
49
|
"helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
|
|
47
50
|
"handleBlocked": "先处理",
|
|
48
51
|
"resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
|
|
@@ -205,6 +208,9 @@ window.__ModuleLoader__.load({
|
|
|
205
208
|
"helpNobody": "Not started yet",
|
|
206
209
|
"helpDone": "Everything is done",
|
|
207
210
|
"helpNextDone": "Nothing next; this task is complete",
|
|
211
|
+
"helpWaitActive": "Wait for this step to finish, then continue",
|
|
212
|
+
"helpNoPlan": "No work has been scheduled yet",
|
|
213
|
+
"nowRemaining": "{n} item(s) left",
|
|
208
214
|
"helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
|
|
209
215
|
"handleBlocked": "Fix first",
|
|
210
216
|
"resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
|
|
@@ -1026,14 +1032,21 @@ window.__ModuleLoader__.load({
|
|
|
1026
1032
|
return text;
|
|
1027
1033
|
}
|
|
1028
1034
|
function workerText(session, t) {
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1035
|
+
/*
|
|
1036
|
+
* [1.5.2] 与 `planNow` 同序:卡住优先。
|
|
1037
|
+
*
|
|
1038
|
+
* 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
|
|
1039
|
+
* 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
|
|
1040
|
+
* 卡住需要人介入,两个位置都应先报它。
|
|
1041
|
+
*/
|
|
1033
1042
|
const blocked = findEntry(session, 'blocked');
|
|
1034
1043
|
if (blocked) {
|
|
1035
1044
|
return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`;
|
|
1036
1045
|
}
|
|
1046
|
+
const active = findEntry(session, 'in_progress');
|
|
1047
|
+
if (active) {
|
|
1048
|
+
return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
|
|
1049
|
+
}
|
|
1037
1050
|
const stats = session.stats || {};
|
|
1038
1051
|
if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
|
|
1039
1052
|
return t('helpDone');
|
|
@@ -1060,13 +1073,26 @@ window.__ModuleLoader__.load({
|
|
|
1060
1073
|
const blocked = findEntry(session, 'blocked');
|
|
1061
1074
|
if (blocked)
|
|
1062
1075
|
return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`;
|
|
1076
|
+
/*
|
|
1077
|
+
* [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
|
|
1078
|
+
*
|
|
1079
|
+
* 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
|
|
1080
|
+
* · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
|
|
1081
|
+
* · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
|
|
1082
|
+
* 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
|
|
1083
|
+
*/
|
|
1084
|
+
const counts = planCounts(session.stats || {});
|
|
1085
|
+
if (counts.total > 0 && counts.done === counts.total)
|
|
1086
|
+
return t('helpDone');
|
|
1087
|
+
if (counts.total === 0)
|
|
1088
|
+
return t('helpNoPlan');
|
|
1063
1089
|
const next = findEntry(session, 'pending') || allEntries(session).find((entry) => !entry.item);
|
|
1064
1090
|
if (next)
|
|
1065
1091
|
return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`;
|
|
1066
|
-
const
|
|
1067
|
-
if (
|
|
1068
|
-
return t('
|
|
1069
|
-
return t('
|
|
1092
|
+
const active = findEntry(session, 'in_progress');
|
|
1093
|
+
if (active)
|
|
1094
|
+
return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
|
|
1095
|
+
return t('helpWaitActive');
|
|
1070
1096
|
}
|
|
1071
1097
|
function progressText(item, t) {
|
|
1072
1098
|
const status = item?.status || 'pending';
|
|
@@ -1254,15 +1280,19 @@ window.__ModuleLoader__.load({
|
|
|
1254
1280
|
const done = items.filter(({ item }) => item?.status === 'done').length;
|
|
1255
1281
|
const blocked = items.filter(({ item }) => item?.status === 'blocked').length;
|
|
1256
1282
|
const inProgress = items.some(({ item }) => item?.status === 'in_progress');
|
|
1257
|
-
|
|
1283
|
+
// [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
|
|
1284
|
+
// 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
|
|
1285
|
+
// 在阶段卡显示「还没开始」(T5 C9)。
|
|
1286
|
+
const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle';
|
|
1258
1287
|
// Same tone/counts as before; only the presentation moved into the
|
|
1259
1288
|
// collapsed summary row (done/total + state pill), so the numbers keep one
|
|
1260
1289
|
// source: this step's own items from `statuses`.
|
|
1261
1290
|
const stateText = blocked > 0
|
|
1262
1291
|
? `${blocked} ${t('stats.blocked')}`
|
|
1263
|
-
: done === items.length
|
|
1292
|
+
: items.length > 0 && done === items.length
|
|
1264
1293
|
? t('helpDone')
|
|
1265
|
-
: inProgress ? t('phase.executing')
|
|
1294
|
+
: inProgress ? t('phase.executing')
|
|
1295
|
+
: done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending');
|
|
1266
1296
|
return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` }, h(Collapse, {
|
|
1267
1297
|
className: 'dsh-wp-stage-collapse',
|
|
1268
1298
|
panelClassName: 'dsh-wp-stage-body',
|
|
@@ -1290,6 +1320,17 @@ window.__ModuleLoader__.load({
|
|
|
1290
1320
|
return 0;
|
|
1291
1321
|
return Math.max(0, Math.round(number));
|
|
1292
1322
|
}
|
|
1323
|
+
/*
|
|
1324
|
+
* [1.5.2] 计划级百分比的唯一算式。
|
|
1325
|
+
*
|
|
1326
|
+
* 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
|
|
1327
|
+
* 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
|
|
1328
|
+
* 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
|
|
1329
|
+
* 现在只有这一个函数,其它地方一律调用它。
|
|
1330
|
+
*/
|
|
1331
|
+
function planPercent(done, total) {
|
|
1332
|
+
return total > 0 ? Math.round((done / total) * 100) : null;
|
|
1333
|
+
}
|
|
1293
1334
|
// 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
|
|
1294
1335
|
// 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
|
|
1295
1336
|
function planCounts(stats) {
|
|
@@ -1299,8 +1340,46 @@ window.__ModuleLoader__.load({
|
|
|
1299
1340
|
const blocked = Math.min(planCount(source.blocked), total - done);
|
|
1300
1341
|
const active = Math.min(planCount(source.inProgress), total - done - blocked);
|
|
1301
1342
|
const pending = Math.max(0, total - done - blocked - active);
|
|
1302
|
-
|
|
1303
|
-
|
|
1343
|
+
return { total, done, active, blocked, pending, percent: planPercent(done, total) };
|
|
1344
|
+
}
|
|
1345
|
+
/*
|
|
1346
|
+
* [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
|
|
1347
|
+
*
|
|
1348
|
+
* 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
|
|
1349
|
+
* 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
|
|
1350
|
+
* 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
|
|
1351
|
+
* 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
|
|
1352
|
+
*
|
|
1353
|
+
* 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
|
|
1354
|
+
* 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
|
|
1355
|
+
* 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
|
|
1356
|
+
*/
|
|
1357
|
+
function planPhaseOf(rawPhase, counts) {
|
|
1358
|
+
const phase = String(rawPhase ?? 'pending');
|
|
1359
|
+
if (counts.total > 0) {
|
|
1360
|
+
if (counts.done === counts.total)
|
|
1361
|
+
return 'done';
|
|
1362
|
+
if (phase === 'done') {
|
|
1363
|
+
if (counts.blocked > 0)
|
|
1364
|
+
return 'blocked';
|
|
1365
|
+
if (counts.active > 0)
|
|
1366
|
+
return 'executing';
|
|
1367
|
+
return 'pending';
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return phase;
|
|
1371
|
+
}
|
|
1372
|
+
/*
|
|
1373
|
+
* [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
|
|
1374
|
+
*
|
|
1375
|
+
* 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
|
|
1376
|
+
* fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
|
|
1377
|
+
*/
|
|
1378
|
+
function planWeightedPercent(stats, counts) {
|
|
1379
|
+
const weighted = clampPct(stats?.progress ?? 0);
|
|
1380
|
+
if (counts.total > 0 && counts.done === counts.total)
|
|
1381
|
+
return 100;
|
|
1382
|
+
return Math.min(99, weighted);
|
|
1304
1383
|
}
|
|
1305
1384
|
function planStatusMap(statuses) {
|
|
1306
1385
|
const byId = new Map();
|
|
@@ -1384,6 +1463,16 @@ window.__ModuleLoader__.load({
|
|
|
1384
1463
|
}
|
|
1385
1464
|
if (counts.total > 0 && counts.done === counts.total)
|
|
1386
1465
|
return { state: 'done', text: t('helpDone') };
|
|
1466
|
+
/*
|
|
1467
|
+
* [1.5.2] 部分完成不再落到「还没开始」。
|
|
1468
|
+
*
|
|
1469
|
+
* `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
|
|
1470
|
+
* pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
|
|
1471
|
+
* 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
|
|
1472
|
+
*/
|
|
1473
|
+
if (counts.done > 0) {
|
|
1474
|
+
return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) };
|
|
1475
|
+
}
|
|
1387
1476
|
return { state: 'pending', text: t('stats.pending') };
|
|
1388
1477
|
}
|
|
1389
1478
|
function PlanRing({ percent }) {
|
|
@@ -1456,7 +1545,9 @@ window.__ModuleLoader__.load({
|
|
|
1456
1545
|
if (rows.length < 1 || rows.length > 12)
|
|
1457
1546
|
return null;
|
|
1458
1547
|
return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) }, rows.map((row) => {
|
|
1459
|
-
|
|
1548
|
+
// [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
|
|
1549
|
+
// 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
|
|
1550
|
+
const reach = row.total > 0 ? (row.done / row.total) * 100 : 0;
|
|
1460
1551
|
const width = row.tone === 'pending' ? 0 : reach;
|
|
1461
1552
|
return h('span', {
|
|
1462
1553
|
key: row.id,
|
|
@@ -1473,11 +1564,20 @@ window.__ModuleLoader__.load({
|
|
|
1473
1564
|
}));
|
|
1474
1565
|
}
|
|
1475
1566
|
function PlanViz({ t, counts, now, rows }) {
|
|
1567
|
+
/*
|
|
1568
|
+
* [1.5.2] data-state 也是「完成语义」的一个读者。
|
|
1569
|
+
*
|
|
1570
|
+
* 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
|
|
1571
|
+
* 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
|
|
1572
|
+
* 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
|
|
1573
|
+
*/
|
|
1476
1574
|
const state = counts.percent === null
|
|
1477
1575
|
? 'idle'
|
|
1478
1576
|
: counts.blocked > 0
|
|
1479
1577
|
? 'blocked'
|
|
1480
|
-
: counts.
|
|
1578
|
+
: counts.total > 0 && counts.done === counts.total
|
|
1579
|
+
? 'done'
|
|
1580
|
+
: counts.done > 0 || counts.active > 0 ? 'active' : 'idle';
|
|
1481
1581
|
return h('div', {
|
|
1482
1582
|
className: 'dsh-wp-planviz',
|
|
1483
1583
|
'data-percent': counts.percent === null ? 'none' : String(counts.percent),
|
|
@@ -1496,7 +1596,7 @@ window.__ModuleLoader__.load({
|
|
|
1496
1596
|
className: 'dsh-wp-planoverview',
|
|
1497
1597
|
'data-plan-total': String(counts.total),
|
|
1498
1598
|
'data-plan-done': String(counts.done),
|
|
1499
|
-
}, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${
|
|
1599
|
+
}, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
|
|
1500
1600
|
? h('div', { className: 'dsh-wp-planrows' }, rows.map((row) => {
|
|
1501
1601
|
const width = row.total > 0 ? (row.done / row.total) * 100 : 0;
|
|
1502
1602
|
return h('div', {
|
|
@@ -1514,13 +1614,18 @@ window.__ModuleLoader__.load({
|
|
|
1514
1614
|
}
|
|
1515
1615
|
function SessionCard({ session, t, open, onToggle }) {
|
|
1516
1616
|
const domId = `dsh-wp-session-${session.sessionId}`;
|
|
1517
|
-
const phase = session.phase || 'pending';
|
|
1518
1617
|
const stats = session.stats || {};
|
|
1519
|
-
const tone = phaseTone(phase);
|
|
1520
|
-
const departments = Array.isArray(session.departments) ? session.departments : [];
|
|
1521
1618
|
// 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
|
|
1522
1619
|
// 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
|
|
1523
1620
|
const counts = planCounts(stats);
|
|
1621
|
+
/*
|
|
1622
|
+
* [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
|
|
1623
|
+
* 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
|
|
1624
|
+
* 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
|
|
1625
|
+
*/
|
|
1626
|
+
const phase = planPhaseOf(session.phase || 'pending', counts);
|
|
1627
|
+
const tone = phaseTone(phase);
|
|
1628
|
+
const departments = Array.isArray(session.departments) ? session.departments : [];
|
|
1524
1629
|
const statuses = Array.isArray(session.statuses) ? session.statuses : [];
|
|
1525
1630
|
const stageRows = planStageRows(session.stages, statuses);
|
|
1526
1631
|
const now = planNow(t, counts, planTaskEntries(session.stages, statuses));
|
|
@@ -1535,7 +1640,7 @@ window.__ModuleLoader__.load({
|
|
|
1535
1640
|
badge: h(Pill, { tone }, t(`phase.${phase}`)),
|
|
1536
1641
|
}, session.updatedAt
|
|
1537
1642
|
? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
|
|
1538
|
-
: null, h(
|
|
1643
|
+
: null, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, session.degraded ? h('div', { className: 'dsh-wp-banner dsh-wp-banner--degraded' }, h('strong', null, t('degraded')), h('div', null, plainError(session.error || '')), h('div', { className: 'dsh-wp-muted' }, t('degradedHint'))) : null, h(Guide, { session, t }), departments.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('who')), h('div', { className: 'dsh-wp-departments' }, departments.map((dept) => h(Pill, { key: dept.id, tone: dept.blocked > 0 ? 'blocked' : dept.done === dept.total ? 'done' : 'active' }, `${deptName(t, dept.id)} ${dept.done}/${dept.total} ${t('stats.done')}`)))) : null, h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('steps')), (session.stages || []).map((stage, index) => h(StageCard, {
|
|
1539
1644
|
key: stage.id,
|
|
1540
1645
|
stage,
|
|
1541
1646
|
statuses: session.statuses || [],
|
|
@@ -1729,7 +1834,7 @@ window.__ModuleLoader__.load({
|
|
|
1729
1834
|
total,
|
|
1730
1835
|
done,
|
|
1731
1836
|
open: total - done,
|
|
1732
|
-
percent:
|
|
1837
|
+
percent: planPercent(done, total),
|
|
1733
1838
|
active: Math.max(0, active),
|
|
1734
1839
|
pending: Math.max(0, pending),
|
|
1735
1840
|
blocked: Math.max(0, blocked),
|
|
@@ -1919,10 +2024,11 @@ window.__ModuleLoader__.load({
|
|
|
1919
2024
|
function TaskBars({ sessions, t, onSelect }) {
|
|
1920
2025
|
return h('section', { className: 'dsh-wp-chart-card dsh-wp-task-bars' }, h('div', { className: 'dsh-wp-section-title' }, t('taskChart')), (Array.isArray(sessions) ? sessions : []).map((session) => {
|
|
1921
2026
|
const stats = session.stats || {};
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
const percent =
|
|
1925
|
-
const
|
|
2027
|
+
const counts = planCounts(stats);
|
|
2028
|
+
// 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
|
|
2029
|
+
const percent = counts.percent ?? 0;
|
|
2030
|
+
const phase = planPhaseOf(session.phase || 'pending', counts);
|
|
2031
|
+
const tone = phaseTone(phase);
|
|
1926
2032
|
return h('button', {
|
|
1927
2033
|
key: session.sessionId,
|
|
1928
2034
|
type: 'button',
|
|
@@ -1930,7 +2036,7 @@ window.__ModuleLoader__.load({
|
|
|
1930
2036
|
'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
|
|
1931
2037
|
onClick: () => { if (typeof onSelect === 'function')
|
|
1932
2038
|
onSelect(session.sessionId); },
|
|
1933
|
-
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${
|
|
2039
|
+
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${phase}`)), h('span', null, `${percent}%`)));
|
|
1934
2040
|
}));
|
|
1935
2041
|
}
|
|
1936
2042
|
function Dashboard({ t }) {
|
|
@@ -1980,8 +2086,16 @@ window.__ModuleLoader__.load({
|
|
|
1980
2086
|
const live = [];
|
|
1981
2087
|
const finished = [];
|
|
1982
2088
|
const urgentPhases = ['blocked', 'degraded', 'paused'];
|
|
2089
|
+
/*
|
|
2090
|
+
* [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
|
|
2091
|
+
*
|
|
2092
|
+
* 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
|
|
2093
|
+
* 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
|
|
2094
|
+
* 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
|
|
2095
|
+
* `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
|
|
2096
|
+
*/
|
|
1983
2097
|
for (const session of activeSessions) {
|
|
1984
|
-
if (session.phase === 'done')
|
|
2098
|
+
if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done')
|
|
1985
2099
|
finished.push(session);
|
|
1986
2100
|
else
|
|
1987
2101
|
live.push(session);
|
|
@@ -2001,7 +2115,7 @@ window.__ModuleLoader__.load({
|
|
|
2001
2115
|
planning: 4,
|
|
2002
2116
|
pending: 5,
|
|
2003
2117
|
};
|
|
2004
|
-
const rank = (session) => phaseRank[session.phase] ?? 5;
|
|
2118
|
+
const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
|
|
2005
2119
|
live.sort((a, b) => (rank(a) - rank(b))
|
|
2006
2120
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
|
|
2007
2121
|
|| String(a.sessionId).localeCompare(String(b.sessionId)));
|
|
@@ -2033,12 +2147,11 @@ window.__ModuleLoader__.load({
|
|
|
2033
2147
|
return { live, finished, urgent: live.filter((session) => urgentPhases.includes(session.phase)).length };
|
|
2034
2148
|
}, [activeSessions]);
|
|
2035
2149
|
const liveBars = useMemo(() => groups.live.slice(0, 6).map((session) => {
|
|
2036
|
-
const doneCount = session.stats?.done ?? 0;
|
|
2037
|
-
const totalCount = session.stats?.total ?? 0;
|
|
2038
2150
|
const raw = String(session.title || t('panel'));
|
|
2039
2151
|
return {
|
|
2040
2152
|
label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
|
|
2041
|
-
|
|
2153
|
+
// [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
|
|
2154
|
+
value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
|
|
2042
2155
|
};
|
|
2043
2156
|
}), [groups, t]);
|
|
2044
2157
|
return h('div', { className: 'dsh-wp' }, h('header', { className: 'dsh-wp-header' }, h('div', null, h('h1', { className: 'dsh-wp-title' }, t('title')), h('p', { className: 'dsh-wp-subtitle' }, t('subtitle')), h('div', { className: 'dsh-wp-meta' }, updatedAt ? h('span', null, `${t('updatedAt')} ${formatTime(updatedAt)}`) : null, h('span', null, `${t('scopeAll')}:${activeSessions.length} ${t('sessionCount')},${t('taskBreakdown')} ${totals.total} ${t('subtaskCount')}`))), h('button', { type: 'button', className: 'dsh-wp-button', onClick: reload, disabled: loading }, t('refresh'))), error ? h(ErrorBanner, { message: error, onRetry: reload, t }) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-statbar', role: 'list' }, h('div', { className: 'dsh-wp-stat dsh-wp-stat--tasks', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, activeSessions.length), h('div', { className: 'dsh-wp-stat-label' }, t('sessionCount'))), h('div', { className: 'dsh-wp-stat', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.total), h('div', { className: 'dsh-wp-stat-label' }, t('workItems'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--done', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.done), h('div', { className: 'dsh-wp-stat-label' }, t('stats.done'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--active', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.inProgress), h('div', { className: 'dsh-wp-stat-label' }, t('stats.inProgress'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--blocked', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.blocked), h('div', { className: 'dsh-wp-stat-label' }, t('stats.blocked')))) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-scope-note' }, t('allPlansHint')) : null, activeSessions.length === 0
|
|
@@ -2066,9 +2179,16 @@ window.__ModuleLoader__.load({
|
|
|
2066
2179
|
if (currentTurn > createdTurn && !active)
|
|
2067
2180
|
return null;
|
|
2068
2181
|
}
|
|
2182
|
+
/*
|
|
2183
|
+
* [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
|
|
2184
|
+
*
|
|
2185
|
+
* 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
|
|
2186
|
+
* 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
|
|
2187
|
+
*/
|
|
2069
2188
|
const stats = session.stats || {};
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2189
|
+
const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
|
|
2190
|
+
const tone = phaseTone(phase);
|
|
2191
|
+
return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, t(`phase.${phase}`))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
|
|
2072
2192
|
}
|
|
2073
2193
|
function contentText(block) {
|
|
2074
2194
|
if (!block || !Array.isArray(block.content))
|
package/lib/host.js
CHANGED