dsh-workplan 1.4.3 → 1.5.0

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 CHANGED
@@ -1,3 +1,55 @@
1
+ ## 1.5.0 — 2026-10-28
2
+
3
+ **「进度必须一眼看得见」的图表化版本**。1.4.3 把数字收敛成单一来源,但计划卡折叠后仍然只有一行
4
+ 12px 灰字(`本计划 0/8 做好了` + 54×5px 迷你条 + `0 卡住了`),0% 时迷你条宽度为 0、轨道色与
5
+ 折叠按钮底色同 token,等于**没有图**;百分比只存在于 `title` 悬停提示里;组头在 0/0 时印
6
+ `未完成 0 / 已完成 0(完成率暂无数据)`。用户以截图指出三处画圈区域「不直观、不一目了然、不醒目」,
7
+ 要求「必须通过图表等形式突出显示」。
8
+
9
+ ### 改动(只动展示层,`plan.ts` / `runtime.ts` / `host.ts` 逻辑零改动)
10
+
11
+ - **折叠卡摘要改为图表**(`src/client.ts`,默认视图即可读,无需展开、无需悬停):
12
+ 44px SVG 环 + 24px 百分比数字 + `本计划 X/Y 做好了` + 一行「现在在做什么」(卡住优先→正在做→
13
+ 全部做完/还没开始,带任务名)+ 8px 四段构成条(按件数分宽,`done/active/blocked/pending`)+
14
+ 11px 状态图例(`做好了 0 · 正在做 1 · 卡住了 0 · 还没开始 7`)+ 6px 阶段步进条
15
+ (每阶段一段,当前阶段描边,`data-stage-id` / `data-tone` / `data-current`)。
16
+ - **展开后新增「分步进度」**:逐阶段行(名称、`done/total`、4px 细条、状态)+加权总体进度,
17
+ 替换旧的一根计划级进度条;折叠态不挂载,保持卡头紧凑。
18
+ - **0% 不再等于「没有图」**:四层信号同时存在——环的零轨加粗到 `rgba(100,116,139,.80)`(≈3.14:1,
19
+ `data-zero="true"`)、24px「0%」、整条灰色「还没开始」构成条、虚线阶段槽 + 当前阶段描边;
20
+ 0% 不画 SVG 弧,避免圆头端点在 12 点方向留下与「已完成」混淆的小点。
21
+ - **组头收口**:0% 组环零轨同样加粗(`data-zero="true"`);0/0(没有工作项)时**删除**
22
+ `未完成 0 / 已完成 0(完成率暂无数据)` 整句,只保留环上的 `—` 与组内空态;100% 组
23
+ (`未完成 0 / 已完成 3(完成率 100%)`)整句保留,因为那正是用户要的结论。
24
+ - **零新增依赖**:进度全部由既有 `session.stats` / `session.stages` / `session.statuses` 派生,
25
+ 纯函数组件,无 `useEffect`、无定时器、不引用 chart.js;`package.json` 无 `dependencies`。
26
+ 注意 `node_modules` 里仍能看到 `chart.js@4.6.1`、`@kurkle/color@0.3.4` 标为 extraneous:
27
+ 它们是 1.4.3 之后那次「移除 chart.js 依赖」改动未执行 `npm prune` 的残留,不出现在
28
+ `package.json`、不参与 `npm run build`,运行时图表库来自 `vendor/chart.umd.js` 路由。
29
+ - **版本**:`1.4.3 → 1.5.0`(minor,新增用户可见能力);`package.json` / `package-lock.json` 两处 /
30
+ `src/host.ts` 的 `meta.version` 同步,`lib/host.js`、`dist/host.js` 由 `npm run build` 生成。
31
+
32
+ ### 验证
33
+
34
+ - `npm run check` → exit 0(strict + noImplicitAny)。
35
+ - `npm test` → **99/99 通过**(1.4.3 基线 90 项 + 新增 `tests/progress-visual.test.ts` 8 项 +
36
+ `tests/version-sync.test.ts` 1 项)。
37
+ 新增用例锁定:0/8 的四个可见信号、3/8→38% 且构成条件数之和等于 total、卡住项
38
+ `data-alarm="true"` 且阶段 `data-tone="blocked"`、三个阶段恰好一个 `data-current="true"`、
39
+ 图例恰好四项且顺序固定、折叠态不挂载分步面板、脏数据不产生 NaN/Infinity/undefined 且不超过 100%、
40
+ 卡头不引入 canvas 或图表库。`version-sync` 锁定 `package.json` = `package-lock.json` 两处 =
41
+ `src/host.ts` 的 `meta.version`,升级时不需要改测试。
42
+ - 同步修改的既有断言:`tests/group-summary.test.ts`(两处 `完成率暂无数据` 改为「不再出现」)、
43
+ `tests/session-collapse.test.ts`(`0 卡住了` → 图例 `卡住了 0` + 可见 `100%`)。
44
+
45
+ ### 回滚
46
+
47
+ - 改造前整树快照:`.backups/v1.4.3-pre-progress-viz-20261028-133958.tar.gz`
48
+ (SHA-256 `b8ccc0efa7c307160739ffecdb4af44daab44b3a0f0c779c74d9d0783f6993c7`)。
49
+ - 代码完成、版本号未升时的中间快照:`.backups/v1.4.3-progress-viz-code-20261028-142153.tar.gz`
50
+ (SHA-256 `c79513f5c0279e3bb291d536fba7a631fa8018e27bc7d4fca500fbabd9e602da`)。
51
+ - 恢复后必须 `npm run build` 并重启 DSH web profile(宿主启动时加载 `lib/host.js`,刷新浏览器不够)。
52
+
1
53
  ## 1.4.3 — 2026-10-28
2
54
 
3
55
  **「同一个数字只能有一个来源」的根治版**。1.4.2 及更早版本里,同一个计划的数字存在两套真相源:
package/README.en.md CHANGED
@@ -102,6 +102,23 @@ grep -RInE "writeFileSync|child_process|eval\(" src
102
102
  npm audit --omit=dev
103
103
  ```
104
104
 
105
+ ## What 1.5.0 changes (progress you can see)
106
+
107
+ 1.5.0 is presentation-only: the collapsed plan card swaps its single grey line ("本计划 0/8 做好了" plus a 54×5px bar) for an actual chart.
108
+
109
+ | Where | Before | After |
110
+ |---|---|---|
111
+ | collapsed plan card | 12px grey text + mini bar + "0 卡住了"; the percentage existed only in the hover `title` | 44px ring + 24px percentage + `本计划 X/Y 做好了` + one "what is happening now" line (blocked first, with the task name) + 8px four-segment composition bar + status legend + stage stepper |
112
+ | expanded panel | one plan-level progress bar | "Step progress": one row per stage (name, `done/total`, thin bar, state) plus a weighted overall figure |
113
+ | a 0% plan | the bar fill was 0px wide and its track used the same token as the card background — effectively no chart | four signals: a thickened ring track, a 24px "0%", a full grey "not started" bar, and dashed stage slots with the current stage outlined |
114
+ | a group with 0 work items | `未完成 0 / 已完成 0(完成率暂无数据)` | that sentence is gone; only the `—` in the ring remains (the in-group empty state still renders when expanded) |
115
+
116
+ None of the counts disappeared — the legend always carries all four buckets with their numbers.
117
+
118
+ **How to see it**: the front end ships in `client.js`, which DSH Web loads at start-up, so restart DSH Web and then reload <http://127.0.0.1:3080>. For an offline comparison open [docs/preview/progress-visual-1.5.0.html](docs/preview/progress-visual-1.5.0.html) (`npm run preview` regenerates it with the same CSS for both versions).
119
+
120
+ No new dependencies, `plan.ts` / `runtime.ts` untouched; rollback is documented in [CHANGELOG.md](CHANGELOG.md).
121
+
105
122
  ## What 1.4.3 fixes
106
123
 
107
124
  1.4.3 is a counting-consistency release. The plugin used to keep two sources of truth — live subtask state and a cached stats snapshot — so the panel could disagree with reality.
@@ -136,8 +153,9 @@ dsh plugin --profile web remove dsh-workplan
136
153
  ```bash
137
154
  npm ci
138
155
  npm run check # tsc --noEmit
139
- npm test # 90 tests, including count invariants and mutation tests
156
+ npm test # 99 tests, including count invariants, mutation tests and the progress chart
140
157
  npm run build # src/*.ts → lib/*.js, client.js
158
+ npm run preview # regenerate the 1.4.3 / 1.5.0 card comparison under docs/preview
141
159
  ```
142
160
 
143
161
  `lib/` and `client.js` are committed build output; CI rebuilds them and fails if they drift.
package/README.md CHANGED
@@ -109,6 +109,23 @@ grep -RInE "writeFileSync|child_process|eval\(" src # 应无命中
109
109
  npm audit --omit=dev
110
110
  ```
111
111
 
112
+ ## 1.5.0 改了什么(进度一眼可见)
113
+
114
+ 1.5.0 只改展示层:把计划卡折叠态那行「本计划 0/8 做好了 + 一根 54×5px 的小条」换成图表组合。
115
+
116
+ | 位置 | 改前 | 改后 |
117
+ |---|---|---|
118
+ | 折叠中的计划卡 | 12px 灰字 + 迷你条 + 「0 卡住了」;百分比只在鼠标悬停的 `title` 里 | 44px 环形图 + 24px 百分比 + `本计划 X/Y 做好了` + 一行「现在在做什么」(卡住优先,带任务名)+ 8px 四段构成条 + 状态图例 + 阶段步进条 |
119
+ | 展开后的面板 | 一根计划级进度条 | 「分步进度」:每个阶段一行(名称、`完成/总数`、细条、状态),外加加权总体进度 |
120
+ | 0% 的计划 | 迷你条填充宽度为 0,轨道又与卡片底色用同一个 token,等于没有图 | 四层信号:加粗的环轨、24px「0%」、整条灰色的「还没开始」构成条、虚线阶段槽 + 当前阶段描边 |
121
+ | 0 个工作项的组头 | `未完成 0 / 已完成 0(完成率暂无数据)` | 整句删除,只留环上的 `—`;组内空态仍在(展开可见) |
122
+
123
+ 「卡住了」这类数字一个都没少,只是换了呈现:图例始终是 `做好了 / 正在做 / 卡住了 / 还没开始` 四项带数字。
124
+
125
+ **怎么看到效果**:插件的前端在 `client.js` 里,DSH Web 进程启动时加载它,所以先重启 DSH Web,再刷新 <http://127.0.0.1:3080>。不想改环境也能离线看对比:打开 [docs/preview/progress-visual-1.5.0.html](docs/preview/progress-visual-1.5.0.html)(`npm run preview` 可重新生成,它用同一份 CSS 渲染 1.4.3 与 1.5.0 的卡头)。
126
+
127
+ 没有新增依赖,`plan.ts` / `runtime.ts` 无改动;回滚方式见 [CHANGELOG.md](CHANGELOG.md)。
128
+
112
129
  ## 1.4.3 修了什么(为什么值得升级)
113
130
 
114
131
  1.4.3 是一次**计数一致性**修复:插件曾同时维护「实时任务状态」和「缓存的统计快照」两份真相,导致面板数字和实际状态对不上(截图里「0 个正在做」但下面有任务正在做,就是它)。
@@ -147,8 +164,9 @@ dsh plugin --profile web remove dsh-workplan
147
164
  ```bash
148
165
  npm ci
149
166
  npm run check # tsc --noEmit
150
- npm test # 90 个测试,含计数不变量与变异测试
167
+ npm test # 99 个测试,含计数不变量、变异测试与进度图表用例
151
168
  npm run build # 编译 src/*.ts → lib/*.js、client.js
169
+ npm run preview # 重新生成 docs/preview 里的 1.4.3 / 1.5.0 卡头对比页
152
170
  ```
153
171
 
154
172
  `lib/` 与 `client.js` 是**提交进仓库的构建产物**,CI 会重建并校验二者无差异。
package/client.js CHANGED
@@ -172,6 +172,8 @@ window.__ModuleLoader__.load({
172
172
  "jumpToTask": "跳转到任务明细",
173
173
  "scopeAll": "当前各会话最新计划汇总",
174
174
  "scopeThis": "本计划",
175
+ "vizStageTitle": "分步进度",
176
+ "vizWeighted": "总体进度(加权)",
175
177
  "allPlansHint": "上面是当前各会话最新计划的合计;历史计划不在此汇总,下面每张卡片只统计本计划。",
176
178
  "openHint": "打开左侧「任务进度」看完整安排。"
177
179
  },
@@ -332,6 +334,8 @@ window.__ModuleLoader__.load({
332
334
  "jumpToTask": "Jump to task details",
333
335
  "scopeAll": "Latest plan per live session",
334
336
  "scopeThis": "This plan",
337
+ "vizStageTitle": "Step progress",
338
+ "vizWeighted": "Overall progress (weighted)",
335
339
  "allPlansHint": "These totals cover the latest plan in each live session; older plans are not included. Each card below counts only its own plan.",
336
340
  "openHint": "Open “Task progress” on the left for the full plan."
337
341
  }
@@ -364,6 +368,9 @@ window.__ModuleLoader__.load({
364
368
  底槽用中性灰而不是品牌色,避免「未完成部分」被误读成状态色。 */
365
369
  --dsh-wp-done-ink: var(--dsh-wp-done-text);
366
370
  --dsh-wp-ring-track: rgba(148,163,184,.30);
371
+ /* 1.5.0 — 进度区图表化:构成条/阶段槽的中性轨道,以及 0% 零轨(≥3:1 图形对比)。 */
372
+ --dsh-wp-track: rgba(148,163,184,.22);
373
+ --dsh-wp-track-strong: rgba(100,116,139,.80);
367
374
  min-width: 0;
368
375
  width: 100%;
369
376
  height: 100%;
@@ -405,6 +412,10 @@ window.__ModuleLoader__.load({
405
412
  .dsh-wp-group-ring { position: relative; width: 44px; height: 44px; flex: none; margin-left: auto; display: inline-grid; place-items: center; }
406
413
  .dsh-wp-group-ring svg { position: absolute; inset: 0; display: block; }
407
414
  .dsh-wp-group-ring-track { stroke: var(--dsh-wp-ring-track, rgba(148,163,184,.30)); }
415
+ /* 1.5.0 — 0% 不等于「没有图」:零轨加粗到 ≥3:1(rgba(100,116,139,.80)),
416
+ 环仍然看得见;「暂无数据」保持细轨 + 「—」,两种状态在视觉上不是一回事。 */
417
+ .dsh-wp-group-ring[data-zero="true"] .dsh-wp-group-ring-track { stroke: var(--dsh-wp-track-strong); }
418
+ .dsh-wp-group-ring[data-zero="true"] .dsh-wp-group-ring-label { font-size: 12px; font-weight: 800; }
408
419
  .dsh-wp-group-ring-arc { stroke: var(--dsh-wp-done-ink, #0284c7); transition: stroke-dasharray .28s ease; }
409
420
  .dsh-wp-group-ring-label { position: relative; font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: -.2px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #0f172a); }
410
421
  @media (prefers-reduced-motion: reduce) { .dsh-wp-group-ring-arc { transition: none; } }
@@ -629,6 +640,84 @@ window.__ModuleLoader__.load({
629
640
  .dsh-wp-mini-fill--blocked { background: var(--dsw-alias-state-error-primary); }
630
641
  .dsh-wp-mini-fill--warn { background: var(--dsw-alias-state-warn-primary); }
631
642
  .dsh-wp-mini-fill--idle { background: var(--dsw-alias-state-idle-primary); }
643
+ /* ── 1.5.0 计划卡进度图表(用户诉求:画圈处必须一眼可读)─────────────────
644
+ 四层信号同时存在,任何一层失效都还能读出进度,且都不依赖颜色:
645
+ ① 44px 环:图形比例;0% 时轨道加粗仍可见,不画弧(避免圆头端点留下小点)。
646
+ ② 24px 百分比:主文本色,浅色底 17:1;「—」表示分母为 0,绝不写 NaN。
647
+ ③ 整行四段构成条:按件数分宽;0% 时是一条完整的灰色「还没开始」,
648
+ 这也是「整条灰=都还没开始」的图形解释。
649
+ ④ 11px 状态图例 + 一行「现在在做什么」+ 6px 阶段步进条。
650
+ 数据只来自既有 session.stats / stages / statuses:无新接口、无依赖、无定时器。 */
651
+ .dsh-wp-session-collapse .dsh-wp-collapse-meta { flex: 1 1 100%; display: block; margin-top: 6px; }
652
+ .dsh-wp-planviz {
653
+ display: grid;
654
+ grid-template-columns: 44px minmax(0, 1fr);
655
+ grid-template-areas: "ring head" "bar bar" "legend legend" "step step";
656
+ column-gap: 10px;
657
+ row-gap: 6px;
658
+ width: 100%;
659
+ min-width: 0;
660
+ }
661
+ .dsh-wp-planviz-ring { grid-area: ring; position: relative; width: 44px; height: 44px; display: inline-grid; place-items: center; }
662
+ .dsh-wp-planviz-ring svg { position: absolute; inset: 0; display: block; }
663
+ .dsh-wp-planring-track { stroke: var(--dsh-wp-track); }
664
+ .dsh-wp-planring-arc { stroke: var(--dsh-wp-done-ink); transition: stroke-dasharray .28s ease; }
665
+ .dsh-wp-planviz-ring[data-zero="true"] .dsh-wp-planring-track { stroke: var(--dsh-wp-track-strong); }
666
+ .dsh-wp-planviz-head { grid-area: head; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }
667
+ .dsh-wp-planviz-pct { font-size: 24px; font-weight: 700; line-height: 1.15; letter-spacing: -.4px; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
668
+ .dsh-wp-planviz-scope { min-width: 0; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
669
+ .dsh-wp-planviz-alarm { font-size: 11px; font-weight: 650; padding: 1px 7px; border-radius: 999px; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); }
670
+ .dsh-wp-planviz-now { flex: 1 1 100%; display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); }
671
+ .dsh-wp-planviz-now-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
672
+ .dsh-wp-planviz[data-state="active"] .dsh-wp-planviz-now-dot { background: var(--dsh-wp-active); }
673
+ .dsh-wp-planviz[data-state="done"] .dsh-wp-planviz-now-dot { background: var(--dsh-wp-done); }
674
+ .dsh-wp-planviz[data-state="blocked"] .dsh-wp-planviz-now-dot { background: var(--dsw-alias-state-error-primary); }
675
+ .dsh-wp-planviz-now-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
676
+ .dsh-wp-planviz-bar { grid-area: bar; display: flex; align-items: stretch; gap: 2px; height: 8px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
677
+ .dsh-wp-planviz-seg { flex: 1 1 0; min-width: 4px; height: 100%; border-radius: 999px; }
678
+ .dsh-wp-planviz-seg[data-kind="done"] { background: var(--dsh-wp-done); }
679
+ .dsh-wp-planviz-seg[data-kind="active"] { background: var(--dsh-wp-active); }
680
+ .dsh-wp-planviz-seg[data-kind="blocked"] { background: var(--dsw-alias-state-error-primary); }
681
+ .dsh-wp-planviz-seg[data-kind="pending"] { background: var(--dsw-alias-state-idle-primary); }
682
+ .dsh-wp-planviz-legend { grid-area: legend; display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
683
+ .dsh-wp-planviz-legend-item { display: inline-flex; align-items: center; gap: 4px; }
684
+ .dsh-wp-planviz-legend-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
685
+ .dsh-wp-planviz-legend-item[data-kind="done"] .dsh-wp-planviz-legend-dot { background: var(--dsh-wp-done); }
686
+ .dsh-wp-planviz-legend-item[data-kind="active"] .dsh-wp-planviz-legend-dot { background: var(--dsh-wp-active); }
687
+ .dsh-wp-planviz-legend-item[data-kind="blocked"] .dsh-wp-planviz-legend-dot { background: var(--dsw-alias-state-error-primary); }
688
+ .dsh-wp-stepper { grid-area: step; display: flex; gap: 3px; min-width: 0; }
689
+ .dsh-wp-stepper-seg { position: relative; flex: 1 1 0; min-width: 0; height: 6px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
690
+ .dsh-wp-stepper-seg[data-tone="pending"] { background: transparent; border: 1px dashed var(--dsh-wp-track-strong); }
691
+ .dsh-wp-stepper-seg[data-tone="blocked"] { box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary); }
692
+ .dsh-wp-stepper-seg[data-current="true"] { outline: 1.5px solid var(--dsh-wp-active-text); outline-offset: 1px; }
693
+ .dsh-wp-stepper-fill { display: block; height: 100%; border-radius: 999px; background: var(--dsh-wp-active); }
694
+ .dsh-wp-stepper-seg[data-tone="done"] .dsh-wp-stepper-fill { background: var(--dsh-wp-done); }
695
+ .dsh-wp-stepper-seg[data-tone="blocked"] .dsh-wp-stepper-fill { background: var(--dsw-alias-state-error-primary); }
696
+ @media (prefers-reduced-motion: reduce) { .dsh-wp-planring-arc { transition: none; } }
697
+ /* 短屏(纵向更紧):先撤图例与阶段条,保住环 + 百分比 + 构成条 + 当前步骤。 */
698
+ @media (max-height: 720px) and (max-width: 600px) {
699
+ .dsh-wp-planviz-legend, .dsh-wp-stepper { display: none; }
700
+ }
701
+ /* 展开态「分步进度」:替换旧的计划级进度条,保留加权进度并给出逐阶段分解。 */
702
+ .dsh-wp-planoverview { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-2); display: grid; gap: 8px; }
703
+ .dsh-wp-planoverview-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); }
704
+ .dsh-wp-planoverview-title { font-size: 12px; font-weight: 650; letter-spacing: .2px; }
705
+ .dsh-wp-planoverview-stats { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
706
+ .dsh-wp-planrows { display: grid; }
707
+ .dsh-wp-planrow { display: grid; grid-template-columns: 7px minmax(0, 1fr) 48px auto auto; align-items: center; gap: 8px; min-height: 28px; padding: 4px 0; border-top: 1px solid var(--dsw-alias-border-l1); }
708
+ .dsh-wp-planrow:first-child { border-top: 0; }
709
+ .dsh-wp-planrow[data-current="true"] { background: rgba(249,115,22,.06); border-radius: 8px; }
710
+ .dsh-wp-planrow-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
711
+ .dsh-wp-planrow[data-tone="done"] .dsh-wp-planrow-dot { background: var(--dsh-wp-done); }
712
+ .dsh-wp-planrow[data-tone="active"] .dsh-wp-planrow-dot { background: var(--dsh-wp-active); }
713
+ .dsh-wp-planrow[data-tone="blocked"] .dsh-wp-planrow-dot { background: var(--dsw-alias-state-error-primary); }
714
+ .dsh-wp-planrow-name { min-width: 0; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #0f172a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
715
+ .dsh-wp-planrow-bar { display: block; height: 4px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
716
+ .dsh-wp-planrow-fill { display: block; height: 100%; border-radius: 999px; background: var(--dsh-wp-active); }
717
+ .dsh-wp-planrow[data-tone="done"] .dsh-wp-planrow-fill { background: var(--dsh-wp-done); }
718
+ .dsh-wp-planrow[data-tone="blocked"] .dsh-wp-planrow-fill { background: var(--dsw-alias-state-error-primary); }
719
+ .dsh-wp-planrow-count { font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
720
+ .dsh-wp-planrow-state { font-size: 11px; color: var(--dsw-alias-label-secondary, #475569); white-space: nowrap; }
632
721
  .dsh-wp-sessions { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 16px; align-items: start; }
633
722
  .dsh-wp-session {
634
723
  min-width: 0;
@@ -1188,18 +1277,253 @@ window.__ModuleLoader__.load({
1188
1277
  const primary = contradictions.primary || {};
1189
1278
  return h('details', { className: 'dsh-wp-details' }, h('summary', null, t('analysis')), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, t('whyHint')), h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('taskUnderstanding')), h('div', { className: 'dsh-wp-grid' }, h(Field, { label: t('deliverable') }, task.deliverable || '—'), h(Field, { label: t('successCriteria') }, h(List, { items: task.successCriteria, empty: '—' })), h(Field, { label: t('constraints') }, h(List, { items: task.constraints, empty: '—' })), h(Field, { label: t('knownFacts') }, h(List, { items: task.knownFacts, empty: '—' })), h(Field, { label: t('unknowns') }, h(List, { items: task.unknowns, empty: '—' })), h(Field, { label: t('assumptions') }, h(List, { items: task.assumptions, empty: '—' })))), primary.statement ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('contradictions')), h(Field, { label: t('primaryContradiction') }, primary.statement), h('div', { className: 'dsh-wp-grid', style: { marginTop: '8px' } }, h(Field, { label: t('evidence') }, h(List, { items: primary.evidence, empty: '—' })), h(Field, { label: t('whyPrimary') }, primary.whyPrimary || '—'), h(Field, { label: t('secondary') }, h(List, { items: contradictions.secondary, empty: '—' })), h(Field, { label: t('transformation') }, h(List, { items: contradictions.transformationConditions, empty: '—' })))) : null, session.risks?.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('risks')), session.risks.map((risk, index) => h('div', { className: 'dsh-wp-field', key: index, style: { marginTop: '6px' } }, h('div', { className: 'dsh-wp-field-value' }, risk.risk), h('div', { className: 'dsh-wp-field-label' }, `${t('likelihood')}: ${levelText(t, risk.likelihood)} · ${t('impact')}: ${risk.impact}`), h('div', { className: 'dsh-wp-muted' }, `${t('mitigation')}: ${risk.mitigation} · ${t('trigger')}: ${risk.trigger}`)))) : null, session.verification?.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('verification')), session.verification.map((item, index) => h('div', { className: 'dsh-wp-field', key: index, style: { marginTop: '6px' } }, h('div', { className: 'dsh-wp-field-value' }, item.item), h('div', { className: 'dsh-wp-muted' }, `${item.method} → ${item.evidence}`)))) : null, session.practiceLoop ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('practiceLoop')), h('div', { className: 'dsh-wp-grid' }, h(Field, { label: t('plan') }, session.practiceLoop.plan), h(Field, { label: t('do') }, session.practiceLoop.do), h(Field, { label: t('check') }, session.practiceLoop.check), h(Field, { label: t('act') }, session.practiceLoop.act))) : null, h('div', { className: 'dsh-wp-section dsh-wp-muted' }, t('advancedHint')), h('div', { className: 'dsh-wp-section dsh-wp-muted dsh-wp-code' }, `${t('planId')}: ${session.planId || '—'}`));
1190
1279
  }
1280
+ // ── 1.5.0 进度图表化:把「做好了 / 卡住了」从一行 12px 文字升级为图表 ──
1281
+ // 数据只来自 session.stats / session.stages / session.statuses;全部是纯函数,
1282
+ // 无副作用、无新依赖;0/0 与脏数据收敛为「—」,绝不输出 NaN / Infinity。
1283
+ const PLAN_RING_SIZE = 44;
1284
+ const PLAN_RING_RADIUS = 18;
1285
+ const PLAN_RING_STROKE = 4;
1286
+ const PLAN_RING_CIRCUMFERENCE = 2 * Math.PI * PLAN_RING_RADIUS;
1287
+ function planCount(value) {
1288
+ const number = Number(value);
1289
+ if (!Number.isFinite(number))
1290
+ return 0;
1291
+ return Math.max(0, Math.round(number));
1292
+ }
1293
+ // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1294
+ // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1295
+ function planCounts(stats) {
1296
+ const source = stats || {};
1297
+ const total = planCount(source.total);
1298
+ const done = Math.min(planCount(source.done), total);
1299
+ const blocked = Math.min(planCount(source.blocked), total - done);
1300
+ const active = Math.min(planCount(source.inProgress), total - done - blocked);
1301
+ const pending = Math.max(0, total - done - blocked - active);
1302
+ const percent = total > 0 ? Math.round((done / total) * 100) : null;
1303
+ return { total, done, active, blocked, pending, percent };
1304
+ }
1305
+ function planStatusMap(statuses) {
1306
+ const byId = new Map();
1307
+ for (const item of Array.isArray(statuses) ? statuses : []) {
1308
+ if (item && item.subtaskId != null)
1309
+ byId.set(String(item.subtaskId), item);
1310
+ }
1311
+ return byId;
1312
+ }
1313
+ function planTaskEntries(stages, statuses) {
1314
+ const byId = planStatusMap(statuses);
1315
+ const entries = [];
1316
+ for (const stage of Array.isArray(stages) ? stages : []) {
1317
+ const subtasks = Array.isArray(stage?.subtasks) ? stage.subtasks : [];
1318
+ for (const subtask of subtasks) {
1319
+ if (subtask?.id == null)
1320
+ continue;
1321
+ const item = byId.get(String(subtask.id));
1322
+ entries.push({ title: String(subtask?.title ?? ''), status: String(item?.status ?? 'pending') });
1323
+ }
1324
+ }
1325
+ return entries;
1326
+ }
1327
+ // 当前阶段=第一个有「正在做」的阶段;否则第一个还没全部做完的阶段。
1328
+ function planCurrentStageId(rows) {
1329
+ const running = rows.find((row) => row.active > 0);
1330
+ if (running)
1331
+ return running.id;
1332
+ const open = rows.find((row) => row.tone !== 'done');
1333
+ return open ? open.id : null;
1334
+ }
1335
+ function planStageRows(stages, statuses) {
1336
+ const byId = planStatusMap(statuses);
1337
+ const rows = [];
1338
+ const list = Array.isArray(stages) ? stages : [];
1339
+ list.forEach((stage, index) => {
1340
+ const subtasks = Array.isArray(stage?.subtasks) ? stage.subtasks : [];
1341
+ let done = 0;
1342
+ let active = 0;
1343
+ let blocked = 0;
1344
+ for (const subtask of subtasks) {
1345
+ const status = subtask?.id == null ? undefined : byId.get(String(subtask.id))?.status;
1346
+ if (status === 'done')
1347
+ done += 1;
1348
+ else if (status === 'in_progress')
1349
+ active += 1;
1350
+ else if (status === 'blocked')
1351
+ blocked += 1;
1352
+ }
1353
+ const total = subtasks.length;
1354
+ const tone = blocked > 0
1355
+ ? 'blocked'
1356
+ : total > 0 && done === total
1357
+ ? 'done'
1358
+ : active > 0 || done > 0 ? 'active' : 'pending';
1359
+ rows.push({
1360
+ id: String(stage?.id ?? `stage-${index}`),
1361
+ name: String(stage?.name ?? ''),
1362
+ index,
1363
+ total,
1364
+ done,
1365
+ active,
1366
+ blocked,
1367
+ tone,
1368
+ current: false,
1369
+ });
1370
+ });
1371
+ const currentId = planCurrentStageId(rows);
1372
+ return rows.map((row) => ({ ...row, current: row.id === currentId }));
1373
+ }
1374
+ // 「现在在做什么」:卡住优先(需要人介入),其次是正在做,再次是全部做完/还没开始。
1375
+ function planNow(t, counts, entries) {
1376
+ const withTitle = (label, title) => (title ? `${label} · ${title}` : label);
1377
+ if (counts.blocked > 0) {
1378
+ const blocked = entries.find((entry) => entry.status === 'blocked');
1379
+ return { state: 'blocked', text: withTitle(t('stats.blocked'), blocked?.title) };
1380
+ }
1381
+ if (counts.active > 0) {
1382
+ const active = entries.find((entry) => entry.status === 'in_progress');
1383
+ return { state: 'active', text: withTitle(t('stats.inProgress'), active?.title) };
1384
+ }
1385
+ if (counts.total > 0 && counts.done === counts.total)
1386
+ return { state: 'done', text: t('helpDone') };
1387
+ return { state: 'pending', text: t('stats.pending') };
1388
+ }
1389
+ function PlanRing({ percent }) {
1390
+ const value = percent === null ? null : Math.max(0, Math.min(100, Math.round(percent)));
1391
+ // 0% 时不画弧:圆头端点会在 12 点方向留下一个与「已完成」混淆的小点。
1392
+ const arc = value === null ? 0 : (value / 100) * PLAN_RING_CIRCUMFERENCE;
1393
+ return h('span', {
1394
+ className: 'dsh-wp-planviz-ring',
1395
+ 'aria-hidden': 'true',
1396
+ 'data-zero': value === 0 ? 'true' : 'false',
1397
+ }, h('svg', { viewBox: `0 0 ${PLAN_RING_SIZE} ${PLAN_RING_SIZE}`, width: PLAN_RING_SIZE, height: PLAN_RING_SIZE, focusable: 'false' }, h('circle', {
1398
+ className: 'dsh-wp-planring-track',
1399
+ cx: PLAN_RING_SIZE / 2,
1400
+ cy: PLAN_RING_SIZE / 2,
1401
+ r: PLAN_RING_RADIUS,
1402
+ fill: 'none',
1403
+ strokeWidth: PLAN_RING_STROKE,
1404
+ }), value !== null && value > 0
1405
+ ? h('circle', {
1406
+ className: 'dsh-wp-planring-arc',
1407
+ cx: PLAN_RING_SIZE / 2,
1408
+ cy: PLAN_RING_SIZE / 2,
1409
+ r: PLAN_RING_RADIUS,
1410
+ fill: 'none',
1411
+ strokeWidth: PLAN_RING_STROKE,
1412
+ strokeLinecap: 'round',
1413
+ strokeDasharray: `${arc} ${PLAN_RING_CIRCUMFERENCE - arc}`,
1414
+ transform: `rotate(-90 ${PLAN_RING_SIZE / 2} ${PLAN_RING_SIZE / 2})`,
1415
+ })
1416
+ : null));
1417
+ }
1418
+ function PlanLegend({ t, counts }) {
1419
+ const items = [
1420
+ { kind: 'done', label: t('stats.done'), count: counts.done },
1421
+ { kind: 'active', label: t('stats.inProgress'), count: counts.active },
1422
+ { kind: 'blocked', label: t('stats.blocked'), count: counts.blocked },
1423
+ { kind: 'pending', label: t('stats.pending'), count: counts.pending },
1424
+ ];
1425
+ return h('div', { className: 'dsh-wp-planviz-legend' }, items.map((item) => h('span', {
1426
+ key: item.kind,
1427
+ className: 'dsh-wp-planviz-legend-item',
1428
+ 'data-kind': item.kind,
1429
+ 'data-count': String(item.count),
1430
+ }, h('span', { className: 'dsh-wp-planviz-legend-dot', 'aria-hidden': 'true' }), `${item.label} ${item.count}`)));
1431
+ }
1432
+ function PlanSegments({ counts }) {
1433
+ const segments = [
1434
+ { kind: 'done', count: counts.done },
1435
+ { kind: 'active', count: counts.active },
1436
+ { kind: 'blocked', count: counts.blocked },
1437
+ { kind: 'pending', count: counts.pending },
1438
+ ];
1439
+ return h('div', {
1440
+ className: 'dsh-wp-planviz-bar',
1441
+ 'aria-hidden': 'true',
1442
+ 'data-total': String(counts.total),
1443
+ }, counts.total === 0
1444
+ ? null
1445
+ : segments.map((segment) => (segment.count > 0
1446
+ ? h('span', {
1447
+ key: segment.kind,
1448
+ className: 'dsh-wp-planviz-seg',
1449
+ 'data-kind': segment.kind,
1450
+ 'data-count': String(segment.count),
1451
+ style: { flexGrow: segment.count },
1452
+ })
1453
+ : null)));
1454
+ }
1455
+ function PlanStepper({ t, rows }) {
1456
+ if (rows.length < 1 || rows.length > 12)
1457
+ return null;
1458
+ return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) }, rows.map((row) => {
1459
+ const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0;
1460
+ const width = row.tone === 'pending' ? 0 : reach;
1461
+ return h('span', {
1462
+ key: row.id,
1463
+ className: 'dsh-wp-stepper-seg',
1464
+ 'data-stage-id': row.id,
1465
+ 'data-tone': row.tone,
1466
+ 'data-current': row.current ? 'true' : 'false',
1467
+ title: `${stageLabel(t, row.index)} ${row.done}/${row.total} ${t('stats.done')}`,
1468
+ }, h('span', {
1469
+ className: 'dsh-wp-stepper-fill',
1470
+ 'data-width': String(Math.round(width)),
1471
+ style: { width: `${width}%` },
1472
+ }));
1473
+ }));
1474
+ }
1475
+ function PlanViz({ t, counts, now, rows }) {
1476
+ const state = counts.percent === null
1477
+ ? 'idle'
1478
+ : counts.blocked > 0
1479
+ ? 'blocked'
1480
+ : counts.percent >= 100 ? 'done' : 'active';
1481
+ return h('div', {
1482
+ className: 'dsh-wp-planviz',
1483
+ 'data-percent': counts.percent === null ? 'none' : String(counts.percent),
1484
+ 'data-alarm': counts.blocked > 0 ? 'true' : 'false',
1485
+ 'data-state': state,
1486
+ }, h(PlanRing, { percent: counts.percent }), h('div', { className: 'dsh-wp-planviz-head' }, h('span', { className: 'dsh-wp-planviz-pct' }, counts.percent === null ? '—' : `${counts.percent}%`), h('span', { className: 'dsh-wp-planviz-scope' }, `${t('scopeThis')} ${counts.done}/${counts.total} ${t('stats.done')}`), counts.blocked > 0
1487
+ ? h('span', { className: 'dsh-wp-planviz-alarm' }, `${counts.blocked} ${t('stats.blocked')}`)
1488
+ : null, h('span', { className: 'dsh-wp-planviz-now', 'data-state': now.state }, h('span', { className: 'dsh-wp-planviz-now-dot', 'aria-hidden': 'true' }), h('span', { className: 'dsh-wp-planviz-now-text' }, now.text))), h(PlanSegments, { counts }), h(PlanLegend, { t, counts }), h(PlanStepper, { t, rows }));
1489
+ }
1490
+ // 展开后的「分步进度」:逐阶段行 + 加权总体进度,替代旧的一根计划级进度条。
1491
+ function PlanOverview({ t, stats, counts, rows }) {
1492
+ const stateOf = (tone) => (tone === 'done'
1493
+ ? t('stats.done')
1494
+ : tone === 'active' ? t('stats.inProgress') : tone === 'blocked' ? t('stats.blocked') : t('stats.pending'));
1495
+ return h('div', {
1496
+ className: 'dsh-wp-planoverview',
1497
+ 'data-plan-total': String(counts.total),
1498
+ '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')} ${clampPct(stats?.progress ?? 0)}%`), 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
+ ? h('div', { className: 'dsh-wp-planrows' }, rows.map((row) => {
1501
+ const width = row.total > 0 ? (row.done / row.total) * 100 : 0;
1502
+ return h('div', {
1503
+ key: row.id,
1504
+ className: 'dsh-wp-planrow',
1505
+ 'data-tone': row.tone,
1506
+ 'data-current': row.current ? 'true' : 'false',
1507
+ }, h('span', { className: 'dsh-wp-planrow-dot', 'aria-hidden': 'true' }), h('span', { className: 'dsh-wp-planrow-name' }, `${stageLabel(t, row.index)}:${row.name}`), h('span', { className: 'dsh-wp-planrow-bar' }, h('span', {
1508
+ className: 'dsh-wp-planrow-fill',
1509
+ 'data-width': String(Math.round(width)),
1510
+ style: { width: `${width}%` },
1511
+ })), h('span', { className: 'dsh-wp-planrow-count' }, `${row.done}/${row.total}`), h('span', { className: 'dsh-wp-planrow-state' }, stateOf(row.tone)));
1512
+ }))
1513
+ : null);
1514
+ }
1191
1515
  function SessionCard({ session, t, open, onToggle }) {
1192
1516
  const domId = `dsh-wp-session-${session.sessionId}`;
1193
1517
  const phase = session.phase || 'pending';
1194
1518
  const stats = session.stats || {};
1195
1519
  const tone = phaseTone(phase);
1196
1520
  const departments = Array.isArray(session.departments) ? session.departments : [];
1197
- // The summary keeps the same numbers the old header showed, read from the
1198
- // same `session.stats` object; collapsing only hides the detail panel.
1199
- const done = stats.done ?? 0;
1200
- const total = stats.total ?? 0;
1201
- const blocked = stats.blocked ?? 0;
1202
- const percent = total > 0 ? Math.round((done / total) * 100) : 0;
1521
+ // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1522
+ // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1523
+ const counts = planCounts(stats);
1524
+ const statuses = Array.isArray(session.statuses) ? session.statuses : [];
1525
+ const stageRows = planStageRows(session.stages, statuses);
1526
+ const now = planNow(t, counts, planTaskEntries(session.stages, statuses));
1203
1527
  return h('article', { id: domId, 'data-session-id': session.sessionId, className: `dsh-wp-session dsh-wp-tone-${tone}` }, h(Collapse, {
1204
1528
  id: `dsh-wp-collapse-${session.sessionId}`,
1205
1529
  open: open === true,
@@ -1207,15 +1531,11 @@ window.__ModuleLoader__.load({
1207
1531
  className: 'dsh-wp-session-collapse',
1208
1532
  panelClassName: 'dsh-wp-session-body',
1209
1533
  title: session.title || t('panel'),
1210
- meta: [
1211
- h('span', { key: 'scope' }, `${t('scopeThis')} ${done}/${total} ${t('stats.done')}`),
1212
- h('span', { key: 'bar', className: 'dsh-wp-mini', title: `${percent}%` }, h('span', { className: `dsh-wp-mini-fill dsh-wp-mini-fill--${tone}`, style: { width: `${percent}%` } })),
1213
- h('span', { key: 'blocked' }, `${blocked} ${t('stats.blocked')}`),
1214
- ],
1534
+ meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1215
1535
  badge: h(Pill, { tone }, t(`phase.${phase}`)),
1216
1536
  }, session.updatedAt
1217
1537
  ? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
1218
- : null, h(Guide, { session, t }), 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(Progress, { value: stats.progress ?? 0, tone }), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${t('stats.progress')} ${clampPct(stats.progress ?? 0)}%`), h('span', null, `${stats.inProgress ?? 0} ${t('stats.inProgress')} · ${stats.pending ?? 0} ${t('stats.pending')}`)), 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, {
1538
+ : null, h(Guide, { session, t }), 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, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, 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, {
1219
1539
  key: stage.id,
1220
1540
  stage,
1221
1541
  statuses: session.statuses || [],
@@ -1431,7 +1751,12 @@ window.__ModuleLoader__.load({
1431
1751
  ? Math.min(100, Math.max(0, percent))
1432
1752
  : null;
1433
1753
  const arc = value === null ? 0 : (value / 100) * circumference;
1434
- return h('span', { className: 'dsh-wp-group-ring', 'aria-hidden': 'true' }, h('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, focusable: 'false' }, h('circle', {
1754
+ return h('span', {
1755
+ className: 'dsh-wp-group-ring',
1756
+ 'aria-hidden': 'true',
1757
+ // 0% 与「暂无数据」必须在视觉上可区分:0% 加粗零轨(≥3:1),无数则细轨 + 破折号。
1758
+ 'data-zero': value === 0 ? 'true' : 'false',
1759
+ }, h('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, focusable: 'false' }, h('circle', {
1435
1760
  className: 'dsh-wp-group-ring-track',
1436
1761
  cx: size / 2,
1437
1762
  cy: size / 2,
@@ -1479,8 +1804,11 @@ window.__ModuleLoader__.load({
1479
1804
  const totals = useMemo(() => groupTotals(list), [list]);
1480
1805
  const groupTitle = t(isDone ? 'groupDone' : 'groupLive');
1481
1806
  const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint');
1807
+ // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
1808
+ // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
1809
+ // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
1482
1810
  const summaryText = totals.percent === null
1483
- ? fill(t('groupSummaryNoRate'), { open: totals.open, done: totals.done })
1811
+ ? null
1484
1812
  : fill(t('groupSummary'), { open: totals.open, done: totals.done, pct: totals.percent });
1485
1813
  // 读屏用完整句:计数、完成率与三态明细一起念,避免只听到一个孤零零的数字。
1486
1814
  const ariaText = totals.total > 0
@@ -1506,7 +1834,9 @@ window.__ModuleLoader__.load({
1506
1834
  collapsible ? h('span', { key: 'chevron', className: 'dsh-wp-group-chevron', 'aria-hidden': 'true' }) : null,
1507
1835
  // 摘要独占一整行(CSS 里 flex-basis:100%):窄面板只截断摘要本身,
1508
1836
  // 组名/徽章/环不会被挤走;完整句放在 title,悬停可读全。
1509
- h('span', { key: 'summary', className: 'dsh-wp-group-sub', title: hintText }, summaryText),
1837
+ summaryText === null
1838
+ ? null
1839
+ : h('span', { key: 'summary', className: 'dsh-wp-group-sub', title: hintText }, summaryText),
1510
1840
  ];
1511
1841
  const head = collapsible
1512
1842
  ? h('button', {
package/lib/host.js CHANGED
@@ -294,7 +294,7 @@ export function apply(ctx, config = {}) {
294
294
  }
295
295
  export const meta = {
296
296
  name,
297
- version: '1.4.3',
297
+ version: '1.5.0',
298
298
  promptOrder: PROMPT_ORDER,
299
299
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
300
300
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workplan",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "Automatic task decomposition, real department subagents, and a responsive workplan dashboard for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,7 +66,8 @@
66
66
  "build": "rm -rf dist && tsc -p tsconfig.json && cp dist/plan.js dist/runtime.js dist/host.js dist/watchdog.js lib/ && cp dist/client.js client.js",
67
67
  "check": "tsc -p tsconfig.json --noEmit",
68
68
  "test": "npm run build && rm -rf dist-tests && tsc -p tsconfig.test.json && node --test dist-tests/tests/*.test.js",
69
- "verify": "npm run check && npm test"
69
+ "verify": "npm run check && npm test",
70
+ "preview": "node scripts/render-preview.ts --emit"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@types/node": "^22.20.5",
package/src/client.ts CHANGED
@@ -174,6 +174,8 @@ window.__ModuleLoader__.load({
174
174
  "jumpToTask": "跳转到任务明细",
175
175
  "scopeAll": "当前各会话最新计划汇总",
176
176
  "scopeThis": "本计划",
177
+ "vizStageTitle": "分步进度",
178
+ "vizWeighted": "总体进度(加权)",
177
179
  "allPlansHint": "上面是当前各会话最新计划的合计;历史计划不在此汇总,下面每张卡片只统计本计划。",
178
180
  "openHint": "打开左侧「任务进度」看完整安排。"
179
181
  },
@@ -334,6 +336,8 @@ window.__ModuleLoader__.load({
334
336
  "jumpToTask": "Jump to task details",
335
337
  "scopeAll": "Latest plan per live session",
336
338
  "scopeThis": "This plan",
339
+ "vizStageTitle": "Step progress",
340
+ "vizWeighted": "Overall progress (weighted)",
337
341
  "allPlansHint": "These totals cover the latest plan in each live session; older plans are not included. Each card below counts only its own plan.",
338
342
  "openHint": "Open “Task progress” on the left for the full plan."
339
343
  }
@@ -367,6 +371,9 @@ window.__ModuleLoader__.load({
367
371
  底槽用中性灰而不是品牌色,避免「未完成部分」被误读成状态色。 */
368
372
  --dsh-wp-done-ink: var(--dsh-wp-done-text);
369
373
  --dsh-wp-ring-track: rgba(148,163,184,.30);
374
+ /* 1.5.0 — 进度区图表化:构成条/阶段槽的中性轨道,以及 0% 零轨(≥3:1 图形对比)。 */
375
+ --dsh-wp-track: rgba(148,163,184,.22);
376
+ --dsh-wp-track-strong: rgba(100,116,139,.80);
370
377
  min-width: 0;
371
378
  width: 100%;
372
379
  height: 100%;
@@ -408,6 +415,10 @@ window.__ModuleLoader__.load({
408
415
  .dsh-wp-group-ring { position: relative; width: 44px; height: 44px; flex: none; margin-left: auto; display: inline-grid; place-items: center; }
409
416
  .dsh-wp-group-ring svg { position: absolute; inset: 0; display: block; }
410
417
  .dsh-wp-group-ring-track { stroke: var(--dsh-wp-ring-track, rgba(148,163,184,.30)); }
418
+ /* 1.5.0 — 0% 不等于「没有图」:零轨加粗到 ≥3:1(rgba(100,116,139,.80)),
419
+ 环仍然看得见;「暂无数据」保持细轨 + 「—」,两种状态在视觉上不是一回事。 */
420
+ .dsh-wp-group-ring[data-zero="true"] .dsh-wp-group-ring-track { stroke: var(--dsh-wp-track-strong); }
421
+ .dsh-wp-group-ring[data-zero="true"] .dsh-wp-group-ring-label { font-size: 12px; font-weight: 800; }
411
422
  .dsh-wp-group-ring-arc { stroke: var(--dsh-wp-done-ink, #0284c7); transition: stroke-dasharray .28s ease; }
412
423
  .dsh-wp-group-ring-label { position: relative; font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: -.2px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #0f172a); }
413
424
  @media (prefers-reduced-motion: reduce) { .dsh-wp-group-ring-arc { transition: none; } }
@@ -632,6 +643,84 @@ window.__ModuleLoader__.load({
632
643
  .dsh-wp-mini-fill--blocked { background: var(--dsw-alias-state-error-primary); }
633
644
  .dsh-wp-mini-fill--warn { background: var(--dsw-alias-state-warn-primary); }
634
645
  .dsh-wp-mini-fill--idle { background: var(--dsw-alias-state-idle-primary); }
646
+ /* ── 1.5.0 计划卡进度图表(用户诉求:画圈处必须一眼可读)─────────────────
647
+ 四层信号同时存在,任何一层失效都还能读出进度,且都不依赖颜色:
648
+ ① 44px 环:图形比例;0% 时轨道加粗仍可见,不画弧(避免圆头端点留下小点)。
649
+ ② 24px 百分比:主文本色,浅色底 17:1;「—」表示分母为 0,绝不写 NaN。
650
+ ③ 整行四段构成条:按件数分宽;0% 时是一条完整的灰色「还没开始」,
651
+ 这也是「整条灰=都还没开始」的图形解释。
652
+ ④ 11px 状态图例 + 一行「现在在做什么」+ 6px 阶段步进条。
653
+ 数据只来自既有 session.stats / stages / statuses:无新接口、无依赖、无定时器。 */
654
+ .dsh-wp-session-collapse .dsh-wp-collapse-meta { flex: 1 1 100%; display: block; margin-top: 6px; }
655
+ .dsh-wp-planviz {
656
+ display: grid;
657
+ grid-template-columns: 44px minmax(0, 1fr);
658
+ grid-template-areas: "ring head" "bar bar" "legend legend" "step step";
659
+ column-gap: 10px;
660
+ row-gap: 6px;
661
+ width: 100%;
662
+ min-width: 0;
663
+ }
664
+ .dsh-wp-planviz-ring { grid-area: ring; position: relative; width: 44px; height: 44px; display: inline-grid; place-items: center; }
665
+ .dsh-wp-planviz-ring svg { position: absolute; inset: 0; display: block; }
666
+ .dsh-wp-planring-track { stroke: var(--dsh-wp-track); }
667
+ .dsh-wp-planring-arc { stroke: var(--dsh-wp-done-ink); transition: stroke-dasharray .28s ease; }
668
+ .dsh-wp-planviz-ring[data-zero="true"] .dsh-wp-planring-track { stroke: var(--dsh-wp-track-strong); }
669
+ .dsh-wp-planviz-head { grid-area: head; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }
670
+ .dsh-wp-planviz-pct { font-size: 24px; font-weight: 700; line-height: 1.15; letter-spacing: -.4px; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
671
+ .dsh-wp-planviz-scope { min-width: 0; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
672
+ .dsh-wp-planviz-alarm { font-size: 11px; font-weight: 650; padding: 1px 7px; border-radius: 999px; color: var(--dsw-alias-state-error-primary, #ef4444); color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 80%, var(--dsw-alias-label-primary, #0f172a) 20%); background: rgba(239,68,68,.12); }
673
+ .dsh-wp-planviz-now { flex: 1 1 100%; display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); }
674
+ .dsh-wp-planviz-now-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
675
+ .dsh-wp-planviz[data-state="active"] .dsh-wp-planviz-now-dot { background: var(--dsh-wp-active); }
676
+ .dsh-wp-planviz[data-state="done"] .dsh-wp-planviz-now-dot { background: var(--dsh-wp-done); }
677
+ .dsh-wp-planviz[data-state="blocked"] .dsh-wp-planviz-now-dot { background: var(--dsw-alias-state-error-primary); }
678
+ .dsh-wp-planviz-now-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
679
+ .dsh-wp-planviz-bar { grid-area: bar; display: flex; align-items: stretch; gap: 2px; height: 8px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
680
+ .dsh-wp-planviz-seg { flex: 1 1 0; min-width: 4px; height: 100%; border-radius: 999px; }
681
+ .dsh-wp-planviz-seg[data-kind="done"] { background: var(--dsh-wp-done); }
682
+ .dsh-wp-planviz-seg[data-kind="active"] { background: var(--dsh-wp-active); }
683
+ .dsh-wp-planviz-seg[data-kind="blocked"] { background: var(--dsw-alias-state-error-primary); }
684
+ .dsh-wp-planviz-seg[data-kind="pending"] { background: var(--dsw-alias-state-idle-primary); }
685
+ .dsh-wp-planviz-legend { grid-area: legend; display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
686
+ .dsh-wp-planviz-legend-item { display: inline-flex; align-items: center; gap: 4px; }
687
+ .dsh-wp-planviz-legend-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
688
+ .dsh-wp-planviz-legend-item[data-kind="done"] .dsh-wp-planviz-legend-dot { background: var(--dsh-wp-done); }
689
+ .dsh-wp-planviz-legend-item[data-kind="active"] .dsh-wp-planviz-legend-dot { background: var(--dsh-wp-active); }
690
+ .dsh-wp-planviz-legend-item[data-kind="blocked"] .dsh-wp-planviz-legend-dot { background: var(--dsw-alias-state-error-primary); }
691
+ .dsh-wp-stepper { grid-area: step; display: flex; gap: 3px; min-width: 0; }
692
+ .dsh-wp-stepper-seg { position: relative; flex: 1 1 0; min-width: 0; height: 6px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
693
+ .dsh-wp-stepper-seg[data-tone="pending"] { background: transparent; border: 1px dashed var(--dsh-wp-track-strong); }
694
+ .dsh-wp-stepper-seg[data-tone="blocked"] { box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary); }
695
+ .dsh-wp-stepper-seg[data-current="true"] { outline: 1.5px solid var(--dsh-wp-active-text); outline-offset: 1px; }
696
+ .dsh-wp-stepper-fill { display: block; height: 100%; border-radius: 999px; background: var(--dsh-wp-active); }
697
+ .dsh-wp-stepper-seg[data-tone="done"] .dsh-wp-stepper-fill { background: var(--dsh-wp-done); }
698
+ .dsh-wp-stepper-seg[data-tone="blocked"] .dsh-wp-stepper-fill { background: var(--dsw-alias-state-error-primary); }
699
+ @media (prefers-reduced-motion: reduce) { .dsh-wp-planring-arc { transition: none; } }
700
+ /* 短屏(纵向更紧):先撤图例与阶段条,保住环 + 百分比 + 构成条 + 当前步骤。 */
701
+ @media (max-height: 720px) and (max-width: 600px) {
702
+ .dsh-wp-planviz-legend, .dsh-wp-stepper { display: none; }
703
+ }
704
+ /* 展开态「分步进度」:替换旧的计划级进度条,保留加权进度并给出逐阶段分解。 */
705
+ .dsh-wp-planoverview { margin: 12px 0 4px; padding: 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-2); display: grid; gap: 8px; }
706
+ .dsh-wp-planoverview-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); }
707
+ .dsh-wp-planoverview-title { font-size: 12px; font-weight: 650; letter-spacing: .2px; }
708
+ .dsh-wp-planoverview-stats { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
709
+ .dsh-wp-planrows { display: grid; }
710
+ .dsh-wp-planrow { display: grid; grid-template-columns: 7px minmax(0, 1fr) 48px auto auto; align-items: center; gap: 8px; min-height: 28px; padding: 4px 0; border-top: 1px solid var(--dsw-alias-border-l1); }
711
+ .dsh-wp-planrow:first-child { border-top: 0; }
712
+ .dsh-wp-planrow[data-current="true"] { background: rgba(249,115,22,.06); border-radius: 8px; }
713
+ .dsh-wp-planrow-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
714
+ .dsh-wp-planrow[data-tone="done"] .dsh-wp-planrow-dot { background: var(--dsh-wp-done); }
715
+ .dsh-wp-planrow[data-tone="active"] .dsh-wp-planrow-dot { background: var(--dsh-wp-active); }
716
+ .dsh-wp-planrow[data-tone="blocked"] .dsh-wp-planrow-dot { background: var(--dsw-alias-state-error-primary); }
717
+ .dsh-wp-planrow-name { min-width: 0; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #0f172a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
718
+ .dsh-wp-planrow-bar { display: block; height: 4px; border-radius: 999px; background: var(--dsh-wp-track); overflow: hidden; }
719
+ .dsh-wp-planrow-fill { display: block; height: 100%; border-radius: 999px; background: var(--dsh-wp-active); }
720
+ .dsh-wp-planrow[data-tone="done"] .dsh-wp-planrow-fill { background: var(--dsh-wp-done); }
721
+ .dsh-wp-planrow[data-tone="blocked"] .dsh-wp-planrow-fill { background: var(--dsw-alias-state-error-primary); }
722
+ .dsh-wp-planrow-count { font-size: 12px; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
723
+ .dsh-wp-planrow-state { font-size: 11px; color: var(--dsw-alias-label-secondary, #475569); white-space: nowrap; }
635
724
  .dsh-wp-sessions { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 16px; align-items: start; }
636
725
  .dsh-wp-session {
637
726
  min-width: 0;
@@ -1333,18 +1422,336 @@ window.__ModuleLoader__.load({
1333
1422
  )
1334
1423
  }
1335
1424
 
1425
+ // ── 1.5.0 进度图表化:把「做好了 / 卡住了」从一行 12px 文字升级为图表 ──
1426
+ // 数据只来自 session.stats / session.stages / session.statuses;全部是纯函数,
1427
+ // 无副作用、无新依赖;0/0 与脏数据收敛为「—」,绝不输出 NaN / Infinity。
1428
+ const PLAN_RING_SIZE = 44
1429
+ const PLAN_RING_RADIUS = 18
1430
+ const PLAN_RING_STROKE = 4
1431
+ const PLAN_RING_CIRCUMFERENCE = 2 * Math.PI * PLAN_RING_RADIUS
1432
+
1433
+ type PlanTone = 'done' | 'active' | 'blocked' | 'pending' | 'idle'
1434
+ type PlanNowState = 'active' | 'blocked' | 'done' | 'pending'
1435
+
1436
+ type PlanCounts = {
1437
+ total: number
1438
+ done: number
1439
+ active: number
1440
+ blocked: number
1441
+ pending: number
1442
+ percent: number | null
1443
+ }
1444
+
1445
+ type PlanStageRow = {
1446
+ id: string
1447
+ name: string
1448
+ index: number
1449
+ total: number
1450
+ done: number
1451
+ active: number
1452
+ blocked: number
1453
+ tone: PlanTone
1454
+ current: boolean
1455
+ }
1456
+
1457
+ type PlanTaskEntry = { title: string; status: string }
1458
+ type PlanNow = { text: string; state: PlanNowState }
1459
+
1460
+ function planCount(value: any): number {
1461
+ const number = Number(value)
1462
+ if (!Number.isFinite(number)) return 0
1463
+ return Math.max(0, Math.round(number))
1464
+ }
1465
+
1466
+ // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1467
+ // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1468
+ function planCounts(stats: any): PlanCounts {
1469
+ const source = stats || {}
1470
+ const total = planCount(source.total)
1471
+ const done = Math.min(planCount(source.done), total)
1472
+ const blocked = Math.min(planCount(source.blocked), total - done)
1473
+ const active = Math.min(planCount(source.inProgress), total - done - blocked)
1474
+ const pending = Math.max(0, total - done - blocked - active)
1475
+ const percent = total > 0 ? Math.round((done / total) * 100) : null
1476
+ return { total, done, active, blocked, pending, percent }
1477
+ }
1478
+
1479
+ function planStatusMap(statuses: any): Map<string, any> {
1480
+ const byId = new Map<string, any>()
1481
+ for (const item of Array.isArray(statuses) ? statuses : []) {
1482
+ if (item && item.subtaskId != null) byId.set(String(item.subtaskId), item)
1483
+ }
1484
+ return byId
1485
+ }
1486
+
1487
+ function planTaskEntries(stages: any, statuses: any): PlanTaskEntry[] {
1488
+ const byId = planStatusMap(statuses)
1489
+ const entries: PlanTaskEntry[] = []
1490
+ for (const stage of Array.isArray(stages) ? stages : []) {
1491
+ const subtasks = Array.isArray(stage?.subtasks) ? stage.subtasks : []
1492
+ for (const subtask of subtasks) {
1493
+ if (subtask?.id == null) continue
1494
+ const item = byId.get(String(subtask.id))
1495
+ entries.push({ title: String(subtask?.title ?? ''), status: String(item?.status ?? 'pending') })
1496
+ }
1497
+ }
1498
+ return entries
1499
+ }
1500
+
1501
+ // 当前阶段=第一个有「正在做」的阶段;否则第一个还没全部做完的阶段。
1502
+ function planCurrentStageId(rows: PlanStageRow[]): string | null {
1503
+ const running = rows.find((row) => row.active > 0)
1504
+ if (running) return running.id
1505
+ const open = rows.find((row) => row.tone !== 'done')
1506
+ return open ? open.id : null
1507
+ }
1508
+
1509
+ function planStageRows(stages: any, statuses: any): PlanStageRow[] {
1510
+ const byId = planStatusMap(statuses)
1511
+ const rows: PlanStageRow[] = []
1512
+ const list = Array.isArray(stages) ? stages : []
1513
+ list.forEach((stage: any, index: number) => {
1514
+ const subtasks = Array.isArray(stage?.subtasks) ? stage.subtasks : []
1515
+ let done = 0
1516
+ let active = 0
1517
+ let blocked = 0
1518
+ for (const subtask of subtasks) {
1519
+ const status = subtask?.id == null ? undefined : byId.get(String(subtask.id))?.status
1520
+ if (status === 'done') done += 1
1521
+ else if (status === 'in_progress') active += 1
1522
+ else if (status === 'blocked') blocked += 1
1523
+ }
1524
+ const total = subtasks.length
1525
+ const tone: PlanTone = blocked > 0
1526
+ ? 'blocked'
1527
+ : total > 0 && done === total
1528
+ ? 'done'
1529
+ : active > 0 || done > 0 ? 'active' : 'pending'
1530
+ rows.push({
1531
+ id: String(stage?.id ?? `stage-${index}`),
1532
+ name: String(stage?.name ?? ''),
1533
+ index,
1534
+ total,
1535
+ done,
1536
+ active,
1537
+ blocked,
1538
+ tone,
1539
+ current: false,
1540
+ })
1541
+ })
1542
+ const currentId = planCurrentStageId(rows)
1543
+ return rows.map((row) => ({ ...row, current: row.id === currentId }))
1544
+ }
1545
+
1546
+ // 「现在在做什么」:卡住优先(需要人介入),其次是正在做,再次是全部做完/还没开始。
1547
+ function planNow(t: (key: string) => string, counts: PlanCounts, entries: PlanTaskEntry[]): PlanNow {
1548
+ const withTitle = (label: string, title?: string): string => (title ? `${label} · ${title}` : label)
1549
+ if (counts.blocked > 0) {
1550
+ const blocked = entries.find((entry) => entry.status === 'blocked')
1551
+ return { state: 'blocked', text: withTitle(t('stats.blocked'), blocked?.title) }
1552
+ }
1553
+ if (counts.active > 0) {
1554
+ const active = entries.find((entry) => entry.status === 'in_progress')
1555
+ return { state: 'active', text: withTitle(t('stats.inProgress'), active?.title) }
1556
+ }
1557
+ if (counts.total > 0 && counts.done === counts.total) return { state: 'done', text: t('helpDone') }
1558
+ return { state: 'pending', text: t('stats.pending') }
1559
+ }
1560
+
1561
+ function PlanRing({ percent }: { percent: number | null }) {
1562
+ const value = percent === null ? null : Math.max(0, Math.min(100, Math.round(percent)))
1563
+ // 0% 时不画弧:圆头端点会在 12 点方向留下一个与「已完成」混淆的小点。
1564
+ const arc = value === null ? 0 : (value / 100) * PLAN_RING_CIRCUMFERENCE
1565
+ return h('span', {
1566
+ className: 'dsh-wp-planviz-ring',
1567
+ 'aria-hidden': 'true',
1568
+ 'data-zero': value === 0 ? 'true' : 'false',
1569
+ },
1570
+ h('svg', { viewBox: `0 0 ${PLAN_RING_SIZE} ${PLAN_RING_SIZE}`, width: PLAN_RING_SIZE, height: PLAN_RING_SIZE, focusable: 'false' },
1571
+ h('circle', {
1572
+ className: 'dsh-wp-planring-track',
1573
+ cx: PLAN_RING_SIZE / 2,
1574
+ cy: PLAN_RING_SIZE / 2,
1575
+ r: PLAN_RING_RADIUS,
1576
+ fill: 'none',
1577
+ strokeWidth: PLAN_RING_STROKE,
1578
+ }),
1579
+ value !== null && value > 0
1580
+ ? h('circle', {
1581
+ className: 'dsh-wp-planring-arc',
1582
+ cx: PLAN_RING_SIZE / 2,
1583
+ cy: PLAN_RING_SIZE / 2,
1584
+ r: PLAN_RING_RADIUS,
1585
+ fill: 'none',
1586
+ strokeWidth: PLAN_RING_STROKE,
1587
+ strokeLinecap: 'round',
1588
+ strokeDasharray: `${arc} ${PLAN_RING_CIRCUMFERENCE - arc}`,
1589
+ transform: `rotate(-90 ${PLAN_RING_SIZE / 2} ${PLAN_RING_SIZE / 2})`,
1590
+ })
1591
+ : null,
1592
+ ),
1593
+ )
1594
+ }
1595
+
1596
+ function PlanLegend({ t, counts }: { t: (key: string) => string; counts: PlanCounts }) {
1597
+ const items = [
1598
+ { kind: 'done', label: t('stats.done'), count: counts.done },
1599
+ { kind: 'active', label: t('stats.inProgress'), count: counts.active },
1600
+ { kind: 'blocked', label: t('stats.blocked'), count: counts.blocked },
1601
+ { kind: 'pending', label: t('stats.pending'), count: counts.pending },
1602
+ ]
1603
+ return h('div', { className: 'dsh-wp-planviz-legend' },
1604
+ items.map((item) => h('span', {
1605
+ key: item.kind,
1606
+ className: 'dsh-wp-planviz-legend-item',
1607
+ 'data-kind': item.kind,
1608
+ 'data-count': String(item.count),
1609
+ },
1610
+ h('span', { className: 'dsh-wp-planviz-legend-dot', 'aria-hidden': 'true' }),
1611
+ `${item.label} ${item.count}`,
1612
+ )),
1613
+ )
1614
+ }
1615
+
1616
+ function PlanSegments({ counts }: { counts: PlanCounts }) {
1617
+ const segments = [
1618
+ { kind: 'done', count: counts.done },
1619
+ { kind: 'active', count: counts.active },
1620
+ { kind: 'blocked', count: counts.blocked },
1621
+ { kind: 'pending', count: counts.pending },
1622
+ ]
1623
+ return h('div', {
1624
+ className: 'dsh-wp-planviz-bar',
1625
+ 'aria-hidden': 'true',
1626
+ 'data-total': String(counts.total),
1627
+ },
1628
+ counts.total === 0
1629
+ ? null
1630
+ : segments.map((segment) => (segment.count > 0
1631
+ ? h('span', {
1632
+ key: segment.kind,
1633
+ className: 'dsh-wp-planviz-seg',
1634
+ 'data-kind': segment.kind,
1635
+ 'data-count': String(segment.count),
1636
+ style: { flexGrow: segment.count },
1637
+ })
1638
+ : null)),
1639
+ )
1640
+ }
1641
+
1642
+ function PlanStepper({ t, rows }: { t: (key: string) => string; rows: PlanStageRow[] }) {
1643
+ if (rows.length < 1 || rows.length > 12) return null
1644
+ return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) },
1645
+ rows.map((row) => {
1646
+ const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0
1647
+ const width = row.tone === 'pending' ? 0 : reach
1648
+ return h('span', {
1649
+ key: row.id,
1650
+ className: 'dsh-wp-stepper-seg',
1651
+ 'data-stage-id': row.id,
1652
+ 'data-tone': row.tone,
1653
+ 'data-current': row.current ? 'true' : 'false',
1654
+ title: `${stageLabel(t, row.index)} ${row.done}/${row.total} ${t('stats.done')}`,
1655
+ },
1656
+ h('span', {
1657
+ className: 'dsh-wp-stepper-fill',
1658
+ 'data-width': String(Math.round(width)),
1659
+ style: { width: `${width}%` },
1660
+ }),
1661
+ )
1662
+ }),
1663
+ )
1664
+ }
1665
+
1666
+ function PlanViz({ t, counts, now, rows }: { t: (key: string) => string; counts: PlanCounts; now: PlanNow; rows: PlanStageRow[] }) {
1667
+ const state = counts.percent === null
1668
+ ? 'idle'
1669
+ : counts.blocked > 0
1670
+ ? 'blocked'
1671
+ : counts.percent >= 100 ? 'done' : 'active'
1672
+ return h('div', {
1673
+ className: 'dsh-wp-planviz',
1674
+ 'data-percent': counts.percent === null ? 'none' : String(counts.percent),
1675
+ 'data-alarm': counts.blocked > 0 ? 'true' : 'false',
1676
+ 'data-state': state,
1677
+ },
1678
+ h(PlanRing, { percent: counts.percent }),
1679
+ h('div', { className: 'dsh-wp-planviz-head' },
1680
+ h('span', { className: 'dsh-wp-planviz-pct' }, counts.percent === null ? '—' : `${counts.percent}%`),
1681
+ h('span', { className: 'dsh-wp-planviz-scope' }, `${t('scopeThis')} ${counts.done}/${counts.total} ${t('stats.done')}`),
1682
+ counts.blocked > 0
1683
+ ? h('span', { className: 'dsh-wp-planviz-alarm' }, `${counts.blocked} ${t('stats.blocked')}`)
1684
+ : null,
1685
+ h('span', { className: 'dsh-wp-planviz-now', 'data-state': now.state },
1686
+ h('span', { className: 'dsh-wp-planviz-now-dot', 'aria-hidden': 'true' }),
1687
+ h('span', { className: 'dsh-wp-planviz-now-text' }, now.text),
1688
+ ),
1689
+ ),
1690
+ h(PlanSegments, { counts }),
1691
+ h(PlanLegend, { t, counts }),
1692
+ h(PlanStepper, { t, rows }),
1693
+ )
1694
+ }
1695
+
1696
+ // 展开后的「分步进度」:逐阶段行 + 加权总体进度,替代旧的一根计划级进度条。
1697
+ function PlanOverview({ t, stats, counts, rows }: { t: (key: string) => string; stats: any; counts: PlanCounts; rows: PlanStageRow[] }) {
1698
+ const stateOf = (tone: PlanTone): string => (tone === 'done'
1699
+ ? t('stats.done')
1700
+ : tone === 'active' ? t('stats.inProgress') : tone === 'blocked' ? t('stats.blocked') : t('stats.pending'))
1701
+ return h('div', {
1702
+ className: 'dsh-wp-planoverview',
1703
+ 'data-plan-total': String(counts.total),
1704
+ 'data-plan-done': String(counts.done),
1705
+ },
1706
+ h('div', { className: 'dsh-wp-planoverview-head' },
1707
+ h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')),
1708
+ h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`),
1709
+ ),
1710
+ h('div', { className: 'dsh-wp-planoverview-stats' },
1711
+ h('span', null, `${t('vizWeighted')} ${clampPct(stats?.progress ?? 0)}%`),
1712
+ h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`),
1713
+ counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null,
1714
+ ),
1715
+ rows.length
1716
+ ? h('div', { className: 'dsh-wp-planrows' },
1717
+ rows.map((row) => {
1718
+ const width = row.total > 0 ? (row.done / row.total) * 100 : 0
1719
+ return h('div', {
1720
+ key: row.id,
1721
+ className: 'dsh-wp-planrow',
1722
+ 'data-tone': row.tone,
1723
+ 'data-current': row.current ? 'true' : 'false',
1724
+ },
1725
+ h('span', { className: 'dsh-wp-planrow-dot', 'aria-hidden': 'true' }),
1726
+ h('span', { className: 'dsh-wp-planrow-name' }, `${stageLabel(t, row.index)}:${row.name}`),
1727
+ h('span', { className: 'dsh-wp-planrow-bar' },
1728
+ h('span', {
1729
+ className: 'dsh-wp-planrow-fill',
1730
+ 'data-width': String(Math.round(width)),
1731
+ style: { width: `${width}%` },
1732
+ }),
1733
+ ),
1734
+ h('span', { className: 'dsh-wp-planrow-count' }, `${row.done}/${row.total}`),
1735
+ h('span', { className: 'dsh-wp-planrow-state' }, stateOf(row.tone)),
1736
+ )
1737
+ }),
1738
+ )
1739
+ : null,
1740
+ )
1741
+ }
1742
+
1336
1743
  function SessionCard({ session, t, open, onToggle }: any) {
1337
1744
  const domId = `dsh-wp-session-${session.sessionId}`
1338
1745
  const phase = session.phase || 'pending'
1339
1746
  const stats = session.stats || {}
1340
1747
  const tone = phaseTone(phase)
1341
1748
  const departments = Array.isArray(session.departments) ? session.departments : []
1342
- // The summary keeps the same numbers the old header showed, read from the
1343
- // same `session.stats` object; collapsing only hides the detail panel.
1344
- const done = stats.done ?? 0
1345
- const total = stats.total ?? 0
1346
- const blocked = stats.blocked ?? 0
1347
- const percent = total > 0 ? Math.round((done / total) * 100) : 0
1749
+ // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1750
+ // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1751
+ const counts = planCounts(stats)
1752
+ const statuses = Array.isArray(session.statuses) ? session.statuses : []
1753
+ const stageRows = planStageRows(session.stages, statuses)
1754
+ const now = planNow(t, counts, planTaskEntries(session.stages, statuses))
1348
1755
  return h('article', { id: domId, 'data-session-id': session.sessionId, className: `dsh-wp-session dsh-wp-tone-${tone}` },
1349
1756
  h(Collapse, {
1350
1757
  id: `dsh-wp-collapse-${session.sessionId}`,
@@ -1353,13 +1760,7 @@ window.__ModuleLoader__.load({
1353
1760
  className: 'dsh-wp-session-collapse',
1354
1761
  panelClassName: 'dsh-wp-session-body',
1355
1762
  title: session.title || t('panel'),
1356
- meta: [
1357
- h('span', { key: 'scope' }, `${t('scopeThis')} ${done}/${total} ${t('stats.done')}`),
1358
- h('span', { key: 'bar', className: 'dsh-wp-mini', title: `${percent}%` },
1359
- h('span', { className: `dsh-wp-mini-fill dsh-wp-mini-fill--${tone}`, style: { width: `${percent}%` } }),
1360
- ),
1361
- h('span', { key: 'blocked' }, `${blocked} ${t('stats.blocked')}`),
1362
- ],
1763
+ meta: h(PlanViz, { t, counts, now, rows: stageRows }),
1363
1764
  badge: h(Pill, { tone }, t(`phase.${phase}`)),
1364
1765
  },
1365
1766
  session.updatedAt
@@ -1371,11 +1772,7 @@ window.__ModuleLoader__.load({
1371
1772
  h('div', null, plainError(session.error || '')),
1372
1773
  h('div', { className: 'dsh-wp-muted' }, t('degradedHint')),
1373
1774
  ) : null,
1374
- h(Progress, { value: stats.progress ?? 0, tone }),
1375
- h('div', { className: 'dsh-wp-progress-meta' },
1376
- h('span', null, `${t('stats.progress')} ${clampPct(stats.progress ?? 0)}%`),
1377
- h('span', null, `${stats.inProgress ?? 0} ${t('stats.inProgress')} · ${stats.pending ?? 0} ${t('stats.pending')}`),
1378
- ),
1775
+ open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null,
1379
1776
  departments.length ? h('div', { className: 'dsh-wp-section' },
1380
1777
  h('div', { className: 'dsh-wp-section-title' }, t('who')),
1381
1778
  h('div', { className: 'dsh-wp-departments' },
@@ -1615,7 +2012,12 @@ window.__ModuleLoader__.load({
1615
2012
  ? Math.min(100, Math.max(0, percent))
1616
2013
  : null
1617
2014
  const arc = value === null ? 0 : (value / 100) * circumference
1618
- return h('span', { className: 'dsh-wp-group-ring', 'aria-hidden': 'true' },
2015
+ return h('span', {
2016
+ className: 'dsh-wp-group-ring',
2017
+ 'aria-hidden': 'true',
2018
+ // 0% 与「暂无数据」必须在视觉上可区分:0% 加粗零轨(≥3:1),无数则细轨 + 破折号。
2019
+ 'data-zero': value === 0 ? 'true' : 'false',
2020
+ },
1619
2021
  h('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, focusable: 'false' },
1620
2022
  h('circle', {
1621
2023
  className: 'dsh-wp-group-ring-track',
@@ -1668,8 +2070,11 @@ window.__ModuleLoader__.load({
1668
2070
  const totals = useMemo(() => groupTotals(list), [list])
1669
2071
  const groupTitle = t(isDone ? 'groupDone' : 'groupLive')
1670
2072
  const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint')
2073
+ // 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
2074
+ // 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
2075
+ // 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
1671
2076
  const summaryText = totals.percent === null
1672
- ? fill(t('groupSummaryNoRate'), { open: totals.open, done: totals.done })
2077
+ ? null
1673
2078
  : fill(t('groupSummary'), { open: totals.open, done: totals.done, pct: totals.percent })
1674
2079
  // 读屏用完整句:计数、完成率与三态明细一起念,避免只听到一个孤零零的数字。
1675
2080
  const ariaText = totals.total > 0
@@ -1695,7 +2100,9 @@ window.__ModuleLoader__.load({
1695
2100
  collapsible ? h('span', { key: 'chevron', className: 'dsh-wp-group-chevron', 'aria-hidden': 'true' }) : null,
1696
2101
  // 摘要独占一整行(CSS 里 flex-basis:100%):窄面板只截断摘要本身,
1697
2102
  // 组名/徽章/环不会被挤走;完整句放在 title,悬停可读全。
1698
- h('span', { key: 'summary', className: 'dsh-wp-group-sub', title: hintText }, summaryText),
2103
+ summaryText === null
2104
+ ? null
2105
+ : h('span', { key: 'summary', className: 'dsh-wp-group-sub', title: hintText }, summaryText),
1699
2106
  ]
1700
2107
  const head = collapsible
1701
2108
  ? h('button', {
package/src/host.ts CHANGED
@@ -325,7 +325,7 @@ export function apply(ctx: any, config: WorkplanConfig = {}) {
325
325
 
326
326
  export const meta = {
327
327
  name,
328
- version: '1.4.3',
328
+ version: '1.5.0',
329
329
  promptOrder: PROMPT_ORDER,
330
330
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
331
331
  }