dsh-workplan 0.0.0-stage → 1.4.3

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/client.js ADDED
@@ -0,0 +1,1893 @@
1
+ "use strict";
2
+ /**
3
+ * Client half of dsh-workplan.
4
+ *
5
+ * Plain React only: no imports from DeepSeek Harness client packages. The
6
+ * sidebar page, the inline turn card, and the two tool rows all read the same
7
+ * same-origin host route and render defensively behind an error boundary.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-workplan',
11
+ factory(require) {
12
+ const React = require('react');
13
+ const h = React.createElement;
14
+ const PANEL_ID = 'workplan';
15
+ const NS = 'dsh-workplan';
16
+ const { useState, useEffect, useCallback, useMemo } = React;
17
+ const LOCALE = {
18
+ "zh": {
19
+ "panel": "任务进度",
20
+ "groupLive": "进行中",
21
+ "groupDone": "已完成",
22
+ "groupLiveHint": "还没结束,正在或即将推进",
23
+ "groupDoneHint": "已全部完成并收尾",
24
+ "groupCollapse": "收起",
25
+ "groupExpand": "展开",
26
+ "groupNeeds": "需处理",
27
+ "groupLiveEmpty": "没有进行中的任务",
28
+ "groupDoneEmpty": "还没有完成的任务",
29
+ "groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
30
+ "groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
31
+ "groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
32
+ "groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
33
+ "groupPlansLabel": "个计划",
34
+ "chartGroups": "两类计划数量占比",
35
+ "chartLive": "进行中计划完成度(按工作项)",
36
+ "chartMore": "另有 {n} 个进行中计划未画入图表",
37
+ "title": "任务进度:一眼看懂",
38
+ "subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
39
+ "helpTitle": "先看这三句",
40
+ "helpWhat": "这件事",
41
+ "helpWho": "现在谁在做",
42
+ "helpNext": "接下来做什么",
43
+ "helpNobody": "还没开始",
44
+ "helpDone": "都做完了",
45
+ "helpNextDone": "没有下一步,这件事已完成",
46
+ "helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
47
+ "handleBlocked": "先处理",
48
+ "resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
49
+ "resumeLeft": "还可续跑",
50
+ "resumeTimes": "次",
51
+ "resumeExhausted": "自动续跑额度已用尽,需要主 Agent 直接接手完成",
52
+ "refresh": "刷新",
53
+ "updatedAt": "更新于",
54
+ "empty": "还没有任务进度",
55
+ "emptyHint": "先发一句话说明你要做什么,系统会自动安排步骤和负责人。",
56
+ "error": "读取失败",
57
+ "retry": "重试",
58
+ "phase": {
59
+ "pending": "还没开始",
60
+ "planning": "正在安排",
61
+ "executing": "正在做",
62
+ "integrating": "正在收尾",
63
+ "done": "已完成",
64
+ "blocked": "卡住了",
65
+ "paused": "预算用完,可续跑",
66
+ "degraded": "安排失败",
67
+ "cancelled": "已取消"
68
+ },
69
+ "status": {
70
+ "pending": "还没开始",
71
+ "in_progress": "正在做",
72
+ "done": "做好了",
73
+ "blocked": "卡住了"
74
+ },
75
+ "dept": {
76
+ "engineering": "工程研发",
77
+ "research": "研究分析",
78
+ "design": "设计",
79
+ "product": "产品",
80
+ "data": "数据",
81
+ "qa": "质量验证",
82
+ "security": "安全",
83
+ "devops": "运维",
84
+ "writing": "文档写作",
85
+ "operations": "运营",
86
+ "legal": "法务合规",
87
+ "finance": "财务"
88
+ },
89
+ "stats": {
90
+ "done": "做好了",
91
+ "blocked": "卡住了",
92
+ "inProgress": "正在做",
93
+ "pending": "还没开始",
94
+ "progress": "总体进度"
95
+ },
96
+ "steps": "分几步做",
97
+ "stepPrefix": "第",
98
+ "stepSuffix": "步",
99
+ "workPrefix": "具体工作",
100
+ "who": "负责团队",
101
+ "person": "负责人",
102
+ "what": "要做出什么",
103
+ "how": "怎么做",
104
+ "doneWhen": "怎样算做好",
105
+ "willDeliver": "做完交付",
106
+ "waitFor": "要等",
107
+ "blockedReason": "卡住原因",
108
+ "progressLabel": "进度",
109
+ "more": "更多细节(不看也不影响理解)",
110
+ "why": "为什么这样安排",
111
+ "whyHint": "先找出最关键的问题,再按顺序解决;每一步做完先检查,再决定下一步。",
112
+ "advanced": "专业说明",
113
+ "advancedHint": "下面是给研发人员看的字段,小白可以跳过。",
114
+ "noBlocked": "没有卡住的工作",
115
+ "analysis": "为什么这样安排",
116
+ "taskUnderstanding": "任务说明",
117
+ "deliverable": "最终要交的东西",
118
+ "successCriteria": "做好后应该达到",
119
+ "constraints": "不能违反的限制",
120
+ "knownFacts": "已经确认的事",
121
+ "unknowns": "还不确定的事",
122
+ "assumptions": "暂时这样认为",
123
+ "contradictions": "难点分析",
124
+ "primaryContradiction": "最关键的问题",
125
+ "evidence": "依据",
126
+ "whyPrimary": "为什么它最关键",
127
+ "secondary": "其他问题",
128
+ "transformation": "什么情况下会变化",
129
+ "checkpoint": "这一步做完先检查",
130
+ "subtasks": "具体工作",
131
+ "role": "负责人",
132
+ "objective": "要做出什么",
133
+ "method": "怎么做",
134
+ "inputs": "开始前需要",
135
+ "outputs": "做完交付",
136
+ "dependsOn": "要等",
137
+ "acceptance": "怎样算做好",
138
+ "risk": "可能的问题",
139
+ "summary": "结果",
140
+ "risks": "需要注意的问题",
141
+ "likelihood": "可能性",
142
+ "level": { "low": "可能性较小", "medium": "有可能", "high": "可能性较大" },
143
+ "impact": "影响",
144
+ "mitigation": "怎么避免",
145
+ "trigger": "什么情况下会发生",
146
+ "verification": "怎么证明做对了",
147
+ "item": "检查项",
148
+ "practiceLoop": "做事四步",
149
+ "plan": "先计划",
150
+ "do": "动手做",
151
+ "check": "看结果",
152
+ "act": "再调整",
153
+ "planId": "内部编号",
154
+ "degraded": "自动安排失败",
155
+ "degradedHint": "这次没有启动部门 Agent。请先说明原因,确认后再重试。",
156
+ "viewHint": "看左侧「任务进度」了解谁在做。",
157
+ "toolUpdateTitle": "更新工作状态",
158
+ "toolDispatchTitle": "重新安排工作",
159
+ "toolFailed": "失败了",
160
+ "toolPreparing": "准备中",
161
+ "toolRunning": "正在做",
162
+ "toolDone": "做好了",
163
+ "toolArgs": "参数",
164
+ "noResult": "暂无结果",
165
+ "sessionCount": "个任务",
166
+ "taskBreakdown": "拆成",
167
+ "subtaskCount": "件具体工作",
168
+ "workItems": "分配工作",
169
+ "overview": "进度总览",
170
+ "statusChart": "状态分布",
171
+ "taskChart": "每个任务的进度",
172
+ "jumpToTask": "跳转到任务明细",
173
+ "scopeAll": "当前各会话最新计划汇总",
174
+ "scopeThis": "本计划",
175
+ "allPlansHint": "上面是当前各会话最新计划的合计;历史计划不在此汇总,下面每张卡片只统计本计划。",
176
+ "openHint": "打开左侧「任务进度」看完整安排。"
177
+ },
178
+ "en": {
179
+ "groupLive": "In progress",
180
+ "groupDone": "Completed",
181
+ "groupLiveHint": "Not finished yet — running or about to run",
182
+ "groupDoneHint": "All work finished and wrapped up",
183
+ "groupCollapse": "Collapse",
184
+ "groupExpand": "Expand",
185
+ "groupNeeds": "needs attention",
186
+ "groupLiveEmpty": "No work in progress",
187
+ "groupDoneEmpty": "Nothing completed yet",
188
+ "groupSummary": "Open {open} / done {done} ({pct}% complete)",
189
+ "groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
190
+ "groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
191
+ "groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
192
+ "groupPlansLabel": "plan(s)",
193
+ "chartGroups": "Plan count by group",
194
+ "chartLive": "In-progress plans by work item",
195
+ "chartMore": "{n} more in-progress plans not drawn",
196
+ "panel": "Task progress",
197
+ "title": "Task progress at a glance",
198
+ "subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
199
+ "helpTitle": "Start with these three",
200
+ "helpWhat": "This task",
201
+ "helpWho": "Who is doing it now",
202
+ "helpNext": "What happens next",
203
+ "helpNobody": "Not started yet",
204
+ "helpDone": "Everything is done",
205
+ "helpNextDone": "Nothing next; this task is complete",
206
+ "helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
207
+ "handleBlocked": "Fix first",
208
+ "resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
209
+ "resumeLeft": "resumable",
210
+ "resumeTimes": "times",
211
+ "resumeExhausted": "The automatic resume budget is used up; the main agent has to finish this directly",
212
+ "refresh": "Refresh",
213
+ "updatedAt": "Updated",
214
+ "empty": "No task progress yet",
215
+ "emptyHint": "Send one sentence about what you need; the system will lay out the steps and owners.",
216
+ "error": "Could not load",
217
+ "retry": "Retry",
218
+ "phase": {
219
+ "pending": "Not started",
220
+ "planning": "Planning",
221
+ "executing": "In progress",
222
+ "integrating": "Wrapping up",
223
+ "done": "Done",
224
+ "blocked": "Stuck",
225
+ "paused": "Paused (budget spent)",
226
+ "degraded": "Planning failed",
227
+ "cancelled": "Cancelled"
228
+ },
229
+ "status": {
230
+ "pending": "Not started",
231
+ "in_progress": "In progress",
232
+ "done": "Done",
233
+ "blocked": "Stuck"
234
+ },
235
+ "dept": {
236
+ "engineering": "Engineering",
237
+ "research": "Research",
238
+ "design": "Design",
239
+ "product": "Product",
240
+ "data": "Data",
241
+ "qa": "QA",
242
+ "security": "Security",
243
+ "devops": "DevOps",
244
+ "writing": "Writing",
245
+ "operations": "Operations",
246
+ "legal": "Legal",
247
+ "finance": "Finance"
248
+ },
249
+ "stats": {
250
+ "done": "Done",
251
+ "blocked": "Stuck",
252
+ "inProgress": "In progress",
253
+ "pending": "Not started",
254
+ "progress": "Overall"
255
+ },
256
+ "steps": "Steps",
257
+ "stepPrefix": "Step",
258
+ "stepSuffix": "",
259
+ "workPrefix": "Work item",
260
+ "who": "Team",
261
+ "person": "Owner",
262
+ "what": "What to make",
263
+ "how": "How",
264
+ "doneWhen": "How we know it is good",
265
+ "willDeliver": "What it delivers",
266
+ "waitFor": "Waits for",
267
+ "blockedReason": "Why it is stuck",
268
+ "progressLabel": "Progress",
269
+ "more": "More detail (optional)",
270
+ "why": "Why it is arranged this way",
271
+ "whyHint": "Find the most important problem first, solve in order, and check after each step before deciding the next one.",
272
+ "advanced": "Technical notes",
273
+ "advancedHint": "These fields are for engineers; a non-technical reader can skip them.",
274
+ "noBlocked": "Nothing is stuck",
275
+ "analysis": "Why it is arranged this way",
276
+ "taskUnderstanding": "What this task means",
277
+ "deliverable": "What you will get",
278
+ "successCriteria": "What good looks like",
279
+ "constraints": "Rules we cannot break",
280
+ "knownFacts": "Confirmed facts",
281
+ "unknowns": "Still unknown",
282
+ "assumptions": "Temporary assumptions",
283
+ "contradictions": "Key difficulty",
284
+ "primaryContradiction": "The most important problem",
285
+ "evidence": "Evidence",
286
+ "whyPrimary": "Why it matters most",
287
+ "secondary": "Other problems",
288
+ "transformation": "What could change it",
289
+ "checkpoint": "Check before moving on",
290
+ "subtasks": "Work items",
291
+ "role": "Owner role",
292
+ "objective": "What to make",
293
+ "method": "How",
294
+ "inputs": "Needed first",
295
+ "outputs": "What it delivers",
296
+ "dependsOn": "Waits for",
297
+ "acceptance": "How we know it is good",
298
+ "risk": "Possible problem",
299
+ "summary": "Result",
300
+ "risks": "Things to watch",
301
+ "likelihood": "Likelihood",
302
+ "level": { "low": "Unlikely", "medium": "Possible", "high": "Likely" },
303
+ "impact": "Impact",
304
+ "mitigation": "How to avoid",
305
+ "trigger": "What triggers it",
306
+ "verification": "How we prove it is right",
307
+ "item": "Check",
308
+ "practiceLoop": "Four working steps",
309
+ "plan": "Plan",
310
+ "do": "Do",
311
+ "check": "Check",
312
+ "act": "Adjust",
313
+ "planId": "Internal ID",
314
+ "degraded": "Planning failed",
315
+ "degradedHint": "No department agents were started. Explain why, confirm, then retry.",
316
+ "viewHint": "Open “Task progress” on the left to see who is doing what.",
317
+ "toolUpdateTitle": "Update work status",
318
+ "toolDispatchTitle": "Arrange the work again",
319
+ "toolFailed": "Failed",
320
+ "toolPreparing": "Preparing",
321
+ "toolRunning": "Running",
322
+ "toolDone": "Done",
323
+ "toolArgs": "Arguments",
324
+ "noResult": "No result yet",
325
+ "sessionCount": "tasks",
326
+ "taskBreakdown": "split into",
327
+ "subtaskCount": "work items",
328
+ "workItems": "Work items",
329
+ "overview": "Progress overview",
330
+ "statusChart": "Status mix",
331
+ "taskChart": "Progress by task",
332
+ "jumpToTask": "Jump to task details",
333
+ "scopeAll": "Latest plan per live session",
334
+ "scopeThis": "This plan",
335
+ "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
+ "openHint": "Open “Task progress” on the left for the full plan."
337
+ }
338
+ };
339
+ function flattenLocale(source, prefix = '', out = {}) {
340
+ for (const [key, value] of Object.entries(source)) {
341
+ const full = prefix ? `${prefix}.${key}` : key;
342
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
343
+ flattenLocale(value, full, out);
344
+ }
345
+ else {
346
+ out[full] = String(value);
347
+ }
348
+ }
349
+ return out;
350
+ }
351
+ const CSS = `
352
+ .dsh-wp, .dsh-wp *, .dsh-wp *::before, .dsh-wp *::after { box-sizing: border-box; }
353
+ .dsh-wp {
354
+ /* User-selected status colors: done = sky blue, in progress = orange. */
355
+ --dsh-wp-done: #0ea5e9;
356
+ --dsh-wp-active: #f97316;
357
+ /* 文本/描边用的自适应色:与主文本色混合,浅色主题自动变深、深色主题自动变浅。
358
+ 裸品牌色在白底只有 2.77 / 2.80:1,18px 与 12px 文字都不达标(WCAG 4.5:1)。*/
359
+ /* 兜底:不支持 color-mix 的旧浏览器(Safari <16.2 / Chrome <111)退回主文本色,
360
+ 可读性不降级,只是失去色相;支持时由下方 @supports 覆盖为混合色。*/
361
+ --dsh-wp-done-text: var(--dsw-alias-label-primary);
362
+ --dsh-wp-active-text: var(--dsw-alias-label-primary);
363
+ /* T6 — 零依赖完成度环:弧线 #0284c7 在浅 #fafafb 3.93:1 / 深 #1b1c1f 4.16:1(≥3:1 图形对比);
364
+ 底槽用中性灰而不是品牌色,避免「未完成部分」被误读成状态色。 */
365
+ --dsh-wp-done-ink: var(--dsh-wp-done-text);
366
+ --dsh-wp-ring-track: rgba(148,163,184,.30);
367
+ min-width: 0;
368
+ width: 100%;
369
+ height: 100%;
370
+ max-height: 100dvh;
371
+ min-height: 0;
372
+ overflow-x: hidden;
373
+ overflow-y: auto;
374
+ -webkit-overflow-scrolling: touch;
375
+ overscroll-behavior-y: contain;
376
+ touch-action: pan-y;
377
+ scrollbar-gutter: stable;
378
+ padding: 20px;
379
+ padding-bottom: calc(24px + env(safe-area-inset-bottom));
380
+ color: var(--dsw-alias-label-primary);
381
+ background: var(--dsw-alias-bg-base);
382
+ font-size: 14px;
383
+ line-height: 1.6;
384
+ }
385
+ .dsh-wp h1, .dsh-wp h2, .dsh-wp h3, .dsh-wp p { margin: 0; }
386
+ /* v1.4.0 — status grouping: in progress vs completed */
387
+ .dsh-wp-groups { display: flex; flex-direction: column; gap: 20px; }
388
+ .dsh-wp-group { border-radius: 16px; padding: 14px 14px 6px; border: 1px solid var(--dsw-alias-line-1, rgba(148,163,184,.22)); background: var(--dsw-alias-bg-elevated, rgba(148,163,184,.05)); position: relative; overflow: hidden; }
389
+ .dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
390
+ .dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
391
+ .dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
392
+ /* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
393
+ .dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
394
+ .dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
395
+ .dsh-wp-group-head--btn:hover .dsh-wp-group-chevron,
396
+ .dsh-wp-group-head--btn:focus-visible .dsh-wp-group-chevron { border-color: var(--dsw-alias-label-secondary); }
397
+ .dsh-wp-group-head--btn:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 2px; }
398
+ /* 折叠 chevron:向下=已展开,向右=已折叠(形状编码,不依赖颜色) */
399
+ .dsh-wp-group-chevron { flex: none; width: 7px; height: 7px; border-right: 1.5px solid var(--dsw-alias-label-tertiary, #9ca3af); border-bottom: 1.5px solid var(--dsw-alias-label-tertiary, #9ca3af); transform: rotate(45deg); transition: transform .16s ease; }
400
+ .dsh-wp-group--folded .dsh-wp-group-chevron { transform: rotate(-45deg); }
401
+ /* 折叠态不再给副行降透明度:opacity:.75 会把浅色副行压到 2.45:1,折叠提示由旋转后的 chevron 承担。 */
402
+ @media (prefers-reduced-motion: reduce) { .dsh-wp-group-chevron { transition: none; } }
403
+ /* T6 — 组头完成度环:一个内联 SVG(底槽圆 + 弧线圆),零依赖、无 canvas、无第三方库。
404
+ 环是纯装饰(aria-hidden),百分比同时以文字出现在下一行摘要里,所以不依赖颜色也能读。 */
405
+ .dsh-wp-group-ring { position: relative; width: 44px; height: 44px; flex: none; margin-left: auto; display: inline-grid; place-items: center; }
406
+ .dsh-wp-group-ring svg { position: absolute; inset: 0; display: block; }
407
+ .dsh-wp-group-ring-track { stroke: var(--dsh-wp-ring-track, rgba(148,163,184,.30)); }
408
+ .dsh-wp-group-ring-arc { stroke: var(--dsh-wp-done-ink, #0284c7); transition: stroke-dasharray .28s ease; }
409
+ .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
+ @media (prefers-reduced-motion: reduce) { .dsh-wp-group-ring-arc { transition: none; } }
411
+ .dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
412
+ .dsh-wp-group--live .dsh-wp-group-dot { background: #f97316; box-shadow: 0 0 0 4px rgba(249,115,22,.16); animation: dsh-wp-pulse 1.8s ease-in-out infinite; }
413
+ .dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
414
+ .dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
415
+ /* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
416
+ 15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
417
+ 圆点、卡片左栏,以及计数徽章 14% 底色(文字仍是主文本色,浅/深主题同源)。
418
+ 状态区分同时有文字(进行中/已完成)与形状(左栏、折叠 chevron),不依赖颜色。 */
419
+ .dsh-wp-group-count { min-width: 22px; padding: 0 7px; height: 20px; border-radius: 999px; font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-primary, #0f172a); font-variant-numeric: tabular-nums; }
420
+ .dsh-wp-group--live .dsh-wp-group-count { background: rgba(249,115,22,.14); box-shadow: inset 0 0 0 1px rgba(249,115,22,.34); }
421
+ .dsh-wp-group--done .dsh-wp-group-count { background: rgba(14,165,233,.14); box-shadow: inset 0 0 0 1px rgba(14,165,233,.34); }
422
+ /* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
423
+ 让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
424
+ .dsh-wp-group-alert { font-size: 12px; font-weight: 650; 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); padding: 1px 8px; border-radius: 999px; }
425
+ /* T6 — 组头完成度摘要:先给纯文本「进行中 N / 已完成 M(完成率 x%)」,
426
+ 不点开、不看颜色也能读出进度。flex-basis:100% 让它独占一整行——
427
+ 窄面板(≈292px 内容宽)下只截断摘要自身(省略号 + title 全文),
428
+ 组名、计数徽章、完成度环都不被挤走;读屏由组头按钮的 aria-label 念完整句。 */
429
+ .dsh-wp-group-sub { flex: 0 0 100%; margin-left: 0; min-width: 0; font-size: 12px; line-height: 1.45; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #475569); font-variant-numeric: tabular-nums; }
430
+ .dsh-wp-group-body { display: flex; flex-direction: column; gap: 10px; }
431
+ .dsh-wp-group-body--folded { display: none; }
432
+ /* 已完成的降级感由蓝色左栏 + 组头文案承担;不再整组 opacity:.8——
433
+ 它会把浅色次要文字压到 3.61:1、三级文字压到 2.64:1,直接破坏正文可读性。 */
434
+ .dsh-wp-group-empty { padding: 12px 4px 18px; font-size: 13px; color: var(--dsw-alias-label-secondary, #64748b); }
435
+ .dsh-wp-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 2px 0 2px; }
436
+ .dsh-wp-chart-card { border-radius: 16px; padding: 12px 14px 10px; border: 1px solid var(--dsw-alias-line-1, rgba(148,163,184,.22)); background: linear-gradient(180deg, rgba(148,163,184,.10), rgba(148,163,184,.02)); min-width: 0; }
437
+ .dsh-wp-chart-title { font-size: 12px; font-weight: 650; letter-spacing: .3px; color: var(--dsw-alias-label-secondary, #cbd5e1); margin-bottom: 6px; }
438
+ .dsh-wp-chart-canvas { position: relative; height: 200px; min-width: 0; }
439
+ /* 图表就绪前不占位、就绪后隐去 fallback;两者互斥,避免叠影 */
440
+ .dsh-wp-charts--fallback .dsh-wp-chart-canvas { display: none; }
441
+ .dsh-wp-charts--ready .dsh-wp-fallback { display: none; }
442
+ .dsh-wp-fallback { display: flex; flex-direction: column; gap: 10px; padding: 8px 2px 6px; }
443
+ .dsh-wp-fallback-bar { display: flex; height: 16px; border-radius: 999px; overflow: hidden; background: rgba(148,163,184,.18); }
444
+ .dsh-wp-fallback-live { background: linear-gradient(90deg, #fb923c, #f97316); transition: width .5s ease; }
445
+ .dsh-wp-fallback-done { background: linear-gradient(90deg, #38bdf8, #0284c7); transition: width .5s ease; }
446
+ .dsh-wp-fallback-legend { display: flex; gap: 16px; font-size: 12px; color: var(--dsw-alias-label-secondary, #cbd5e1); }
447
+ .dsh-wp-fallback-legend span { display: inline-flex; align-items: center; gap: 6px; }
448
+ .dsh-wp-fallback-key { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
449
+ .dsh-wp-fallback-key--live { background: #f97316; }
450
+ .dsh-wp-fallback-key--done { background: #0ea5e9; }
451
+ @keyframes dsh-wp-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(249,115,22,.16); } 50% { box-shadow: 0 0 0 7px rgba(249,115,22,.05); } }
452
+ @media (prefers-reduced-motion: reduce) { .dsh-wp-group--live .dsh-wp-group-dot { animation: none; } }
453
+
454
+ .dsh-wp-header {
455
+ display: flex;
456
+ flex-wrap: wrap;
457
+ align-items: flex-start;
458
+ justify-content: space-between;
459
+ gap: 12px;
460
+ margin-bottom: 16px;
461
+ }
462
+ .dsh-wp-title { font-size: clamp(18px, 2.4vw, 24px); font-weight: 650; letter-spacing: -0.01em; }
463
+ .dsh-wp-subtitle { margin-top: 4px; color: var(--dsw-alias-label-secondary); max-width: 70ch; }
464
+ .dsh-wp-guide {
465
+ border: 1px solid var(--dsw-alias-border-l2);
466
+ border-radius: 14px;
467
+ background: var(--dsw-alias-bg-layer-1);
468
+ padding: 12px 14px;
469
+ margin-bottom: 14px;
470
+ }
471
+ .dsh-wp-guide-title { font-weight: 650; margin-bottom: 8px; }
472
+ .dsh-wp-guide-body {
473
+ display: grid;
474
+ grid-template-columns: repeat(3, minmax(0, 1fr));
475
+ gap: 8px;
476
+ margin-top: 8px;
477
+ }
478
+ .dsh-wp-guide-row {
479
+ min-width: 0;
480
+ padding: 8px 10px;
481
+ border: 1px solid var(--dsw-alias-border-l1);
482
+ border-radius: 10px;
483
+ background: var(--dsw-alias-bg-layer-2);
484
+ }
485
+ .dsh-wp-guide-label { color: var(--dsw-alias-label-secondary); font-size: 11px; }
486
+ .dsh-wp-guide-value { min-width: 0; margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; }
487
+ .dsh-wp-guide-hint { margin-top: 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
488
+ .dsh-wp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
489
+ .dsh-wp-scope-note { margin: 8px 0 10px; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.55; }
490
+ .dsh-wp-button {
491
+ min-height: 44px;
492
+ min-width: 44px;
493
+ padding: 8px 14px;
494
+ border-radius: 10px;
495
+ border: 1px solid var(--dsw-alias-border-l1);
496
+ background: var(--dsw-alias-bg-layer-1);
497
+ color: var(--dsw-alias-label-primary);
498
+ font: inherit;
499
+ cursor: pointer;
500
+ touch-action: manipulation;
501
+ }
502
+ .dsh-wp-button:hover { background: var(--dsw-alias-bg-layer-2); }
503
+ .dsh-wp-button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
504
+ .dsh-wp-banner {
505
+ border: 1px solid var(--dsw-alias-border-l1);
506
+ background: var(--dsw-alias-bg-layer-1);
507
+ border-radius: 12px;
508
+ padding: 12px 14px;
509
+ margin-bottom: 14px;
510
+ overflow-wrap: anywhere;
511
+ }
512
+ .dsh-wp-banner--error { border-color: var(--dsw-alias-state-error-primary); }
513
+ .dsh-wp-banner--degraded { border-color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-bg-layer-2); }
514
+ .dsh-wp-empty {
515
+ border: 1px dashed var(--dsw-alias-border-l2);
516
+ border-radius: 14px;
517
+ padding: 28px 18px;
518
+ text-align: center;
519
+ color: var(--dsw-alias-label-secondary);
520
+ overflow-wrap: anywhere;
521
+ }
522
+ .dsh-wp-statbar {
523
+ display: grid;
524
+ grid-template-columns: repeat(5, minmax(0, 1fr));
525
+ border: 1px solid var(--dsw-alias-border-l1);
526
+ background: var(--dsw-alias-bg-layer-1);
527
+ border-radius: 14px;
528
+ overflow: hidden;
529
+ margin-bottom: 16px;
530
+ }
531
+ .dsh-wp-stat {
532
+ min-width: 0;
533
+ padding: 9px 12px 10px;
534
+ border-left: 1px solid var(--dsw-alias-border-l1);
535
+ }
536
+ .dsh-wp-stat:first-child { border-left: 0; }
537
+ .dsh-wp-stat-value { font-size: 18px; line-height: 1.25; font-weight: 650; font-variant-numeric: tabular-nums; }
538
+ .dsh-wp-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; letter-spacing: 0.01em; overflow-wrap: anywhere; }
539
+ .dsh-wp-stat--done .dsh-wp-stat-value { color: var(--dsh-wp-done-text); }
540
+ .dsh-wp-stat--active .dsh-wp-stat-value { color: var(--dsh-wp-active-text); }
541
+ .dsh-wp-stat--tasks .dsh-wp-stat-value { color: var(--dsw-alias-brand-primary); }
542
+ .dsh-wp-stat--blocked .dsh-wp-stat-value { color: var(--dsw-alias-state-error-primary); }
543
+ /* Visual overview: one status donut plus one progress bar per task. Uses only
544
+ the live alias tokens and no chart dependency, so the page stays light. */
545
+ .dsh-wp-overview {
546
+ display: grid;
547
+ grid-template-columns: minmax(220px, 0.85fr) minmax(280px, 1.35fr);
548
+ gap: 12px;
549
+ margin-bottom: 16px;
550
+ }
551
+ .dsh-wp-chart-card {
552
+ min-width: 0;
553
+ border: 1px solid var(--dsw-alias-border-l1);
554
+ background: var(--dsw-alias-bg-layer-1);
555
+ border-radius: 14px;
556
+ padding: 12px 14px;
557
+ overflow-wrap: anywhere;
558
+ }
559
+ .dsh-wp-chart-card .dsh-wp-section-title { margin-top: 0; }
560
+ .dsh-wp-donut-wrap {
561
+ position: relative;
562
+ width: 132px;
563
+ max-width: 100%;
564
+ aspect-ratio: 1;
565
+ margin: 6px auto 10px;
566
+ }
567
+ .dsh-wp-donut { display: block; width: 100%; height: 100%; }
568
+ .dsh-wp-donut-center {
569
+ position: absolute;
570
+ inset: 0;
571
+ display: flex;
572
+ flex-direction: column;
573
+ align-items: center;
574
+ justify-content: center;
575
+ gap: 1px;
576
+ pointer-events: none;
577
+ text-align: center;
578
+ }
579
+ .dsh-wp-donut-center strong { font-size: 24px; line-height: 1.1; font-variant-numeric: tabular-nums; }
580
+ .dsh-wp-donut-center span { color: var(--dsw-alias-label-secondary); font-size: 11px; }
581
+ .dsh-wp-legend { display: flex; flex-wrap: wrap; gap: 5px 12px; justify-content: center; }
582
+ .dsh-wp-legend-item { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
583
+ .dsh-wp-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
584
+ .dsh-wp-bar-row { min-width: 0; }
585
+ .dsh-wp-bar-row + .dsh-wp-bar-row { margin-top: 11px; }
586
+ .dsh-wp-bar-button {
587
+ display: block;
588
+ width: 100%;
589
+ margin: 0;
590
+ padding: 8px 0;
591
+ border: 0;
592
+ background: transparent;
593
+ color: inherit;
594
+ font: inherit;
595
+ text-align: left;
596
+ cursor: pointer;
597
+ border-radius: 10px;
598
+ }
599
+ .dsh-wp-bar-button:hover { background: var(--dsw-alias-bg-layer-1); }
600
+ .dsh-wp-bar-button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
601
+ .dsh-wp-session { scroll-margin-top: 72px; }
602
+ .dsh-wp-bar-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
603
+ .dsh-wp-bar-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
604
+ .dsh-wp-bar-value { flex: 0 0 auto; color: var(--dsw-alias-label-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
605
+ .dsh-wp-bar-track {
606
+ height: 8px;
607
+ border-radius: 999px;
608
+ background: var(--dsw-alias-bg-layer-2);
609
+ overflow: hidden;
610
+ margin-top: 4px;
611
+ }
612
+ .dsh-wp-bar-fill { height: 100%; border-radius: 999px; background: var(--dsh-wp-active); transition: width 240ms ease; }
613
+ .dsh-wp-bar-fill--done { background: var(--dsh-wp-done); }
614
+ .dsh-wp-bar-fill--blocked { background: var(--dsw-alias-state-error-primary); }
615
+ .dsh-wp-bar-fill--warn { background: var(--dsw-alias-state-warn-primary); }
616
+ .dsh-wp-bar-fill--idle { background: var(--dsw-alias-state-idle-primary); }
617
+ .dsh-wp-bar-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 3px; color: var(--dsw-alias-label-secondary); font-size: 11px; }
618
+ .dsh-wp-mini {
619
+ display: inline-block;
620
+ width: 54px;
621
+ height: 5px;
622
+ border-radius: 999px;
623
+ background: var(--dsw-alias-bg-layer-2);
624
+ overflow: hidden;
625
+ vertical-align: middle;
626
+ }
627
+ .dsh-wp-mini-fill { display: block; height: 100%; border-radius: 999px; background: var(--dsh-wp-active); }
628
+ .dsh-wp-mini-fill--done { background: var(--dsh-wp-done); }
629
+ .dsh-wp-mini-fill--blocked { background: var(--dsw-alias-state-error-primary); }
630
+ .dsh-wp-mini-fill--warn { background: var(--dsw-alias-state-warn-primary); }
631
+ .dsh-wp-mini-fill--idle { background: var(--dsw-alias-state-idle-primary); }
632
+ .dsh-wp-sessions { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 16px; align-items: start; }
633
+ .dsh-wp-session {
634
+ min-width: 0;
635
+ border: 1px solid var(--dsw-alias-border-l1);
636
+ border-top: 3px solid var(--dsh-wp-active);
637
+ background: var(--dsw-alias-bg-layer-1);
638
+ border-radius: 16px;
639
+ padding: 16px;
640
+ overflow-wrap: anywhere;
641
+ }
642
+ .dsh-wp-session.dsh-wp-tone-done { border-top-color: var(--dsh-wp-done); }
643
+ .dsh-wp-session.dsh-wp-tone-blocked { border-top-color: var(--dsw-alias-state-error-primary); }
644
+ .dsh-wp-session.dsh-wp-tone-warn { border-top-color: var(--dsw-alias-state-warn-primary); }
645
+
646
+ /* ── 1.4.1 视觉精修:只加细节,不改信息结构 ─────────────────────────────
647
+ 原则(对齐设计稿 §6.4):
648
+ 1) 颜色不做唯一编码:组别另有位置、圆点、对勾/chevron、文案与形状;
649
+ 2) 所有数字用等宽数字(tabular-nums),列与列之间对得齐;
650
+ 3) 只有装饰性元素(发光、渐变、过渡)参与动效,且尊重 reduced-motion。 */
651
+ .dsh-wp-group { box-shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 10px 26px -18px rgba(15,23,42,.55); }
652
+ /* 组左栏:4px 竖向强调条,是「位置+形状」编码,不依赖颜色也能看出分区 */
653
+ .dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
654
+ .dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
655
+ .dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
656
+ .dsh-wp-group--done { opacity: .97; }
657
+ .dsh-wp-group-head--btn { transition: background-color .16s ease; }
658
+ .dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
659
+ .dsh-wp-group-count { font-variant-numeric: tabular-nums; }
660
+ .dsh-wp-group-sub { font-variant-numeric: tabular-nums; }
661
+ /* 图表卡:标题左侧一枚小色块,和环形图/条形图配色呼应 */
662
+ .dsh-wp-chart-title { display: flex; align-items: center; gap: 7px; }
663
+ .dsh-wp-chart-title::before { content: ''; width: 9px; height: 9px; border-radius: 3px; flex: none;
664
+ background: linear-gradient(135deg, #fbbf24, #ea580c); }
665
+ .dsh-wp-chart-card + .dsh-wp-chart-card .dsh-wp-chart-title::before,
666
+ .dsh-wp-chart-card:nth-of-type(2) .dsh-wp-chart-title::before { background: linear-gradient(135deg, #7dd3fc, #0369a1); }
667
+ @media (prefers-reduced-motion: reduce) {
668
+ .dsh-wp-group-head--btn { transition: none; }
669
+ .dsh-wp-charts, .dsh-wp-chart-canvas { animation: none !important; }
670
+ }
671
+ .dsh-wp-session-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; }
672
+ .dsh-wp-session-title { font-size: 16px; font-weight: 650; min-width: 0; }
673
+ .dsh-wp-pills { display: flex; flex-wrap: wrap; gap: 6px; }
674
+ .dsh-wp-pill {
675
+ display: inline-flex;
676
+ align-items: center;
677
+ gap: 5px;
678
+ min-height: 26px;
679
+ padding: 2px 9px;
680
+ border-radius: 999px;
681
+ border: 1px solid var(--dsw-alias-border-l2);
682
+ background: var(--dsw-alias-bg-layer-2);
683
+ color: var(--dsw-alias-label-secondary);
684
+ font-size: 12px;
685
+ white-space: normal;
686
+ overflow-wrap: anywhere;
687
+ }
688
+ .dsh-wp-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; background: var(--dsw-alias-state-idle-primary); }
689
+ .dsh-wp-tone-done .dsh-wp-dot, .dsh-wp-dot--done { background: var(--dsh-wp-done); }
690
+ .dsh-wp-tone-active .dsh-wp-dot, .dsh-wp-dot--in_progress { background: var(--dsh-wp-active); }
691
+ .dsh-wp-pill.dsh-wp-tone-done { color: var(--dsh-wp-done-text); }
692
+ .dsh-wp-pill.dsh-wp-tone-active { color: var(--dsh-wp-active-text); }
693
+ .dsh-wp-tone-blocked .dsh-wp-dot, .dsh-wp-dot--blocked { background: var(--dsw-alias-state-error-primary); }
694
+ .dsh-wp-tone-warn .dsh-wp-dot, .dsh-wp-dot--degraded { background: var(--dsw-alias-state-warn-primary); }
695
+ .dsh-wp-progress {
696
+ height: 8px;
697
+ border-radius: 999px;
698
+ background: var(--dsw-alias-bg-layer-2);
699
+ overflow: hidden;
700
+ margin: 10px 0 6px;
701
+ }
702
+ .dsh-wp-progress-fill { height: 100%; border-radius: 999px; background: var(--dsh-wp-active); transition: width 240ms ease; }
703
+ .dsh-wp-progress-fill--done { background: var(--dsh-wp-done); }
704
+ .dsh-wp-progress-fill--blocked { background: var(--dsw-alias-state-error-primary); }
705
+ .dsh-wp-progress-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
706
+ .dsh-wp-departments { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
707
+ .dsh-wp-section { margin-top: 14px; }
708
+ .dsh-wp-section-title { font-size: 13px; font-weight: 650; color: var(--dsw-alias-label-secondary); margin-bottom: 8px; text-transform: none; }
709
+ .dsh-wp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: 8px; }
710
+ .dsh-wp-field { min-width: 0; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); border-radius: 10px; padding: 8px 10px; }
711
+ .dsh-wp-field-label { color: var(--dsw-alias-label-secondary); font-size: 12px; }
712
+ .dsh-wp-field-value { margin-top: 2px; overflow-wrap: anywhere; }
713
+ .dsh-wp-list { margin: 0; padding-left: 18px; overflow-wrap: anywhere; }
714
+ .dsh-wp-list li + li { margin-top: 3px; }
715
+ .dsh-wp-stage {
716
+ margin-top: 12px;
717
+ border: 1px solid var(--dsw-alias-border-l1);
718
+ border-radius: 12px;
719
+ padding: 12px;
720
+ background: var(--dsw-alias-bg-base);
721
+ min-width: 0;
722
+ }
723
+ .dsh-wp-stage-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; }
724
+ .dsh-wp-stage-title { font-weight: 650; }
725
+ .dsh-wp-stage-goal { color: var(--dsw-alias-label-secondary); margin-top: 2px; }
726
+ .dsh-wp-checkpoint { margin-top: 6px; color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
727
+ .dsh-wp-subtask {
728
+ margin-top: 10px;
729
+ border: 1px solid var(--dsw-alias-border-l1);
730
+ border-radius: 10px;
731
+ padding: 10px;
732
+ background: var(--dsw-alias-bg-layer-1);
733
+ min-width: 0;
734
+ }
735
+ .dsh-wp-subtask-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; }
736
+ .dsh-wp-subtask-title { font-weight: 600; min-width: 0; }
737
+ .dsh-wp-subtask-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
738
+ .dsh-wp-subtask-body { margin-top: 6px; }
739
+ .dsh-wp-subtask-body > div { margin-top: 4px; }
740
+ .dsh-wp-subtask-body p { margin-top: 2px; overflow-wrap: anywhere; }
741
+ .dsh-wp-error-text { color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; }
742
+ .dsh-wp-muted { color: var(--dsw-alias-label-secondary); }
743
+ /* Reusable disclosure container: default collapsed, one 44px+toggle row, panel below. */
744
+ .dsh-wp-collapse { min-width: 0; }
745
+ .dsh-wp-collapse-head { display: flex; align-items: stretch; gap: 6px; min-width: 0; }
746
+ .dsh-wp-collapse-toggle {
747
+ display: flex;
748
+ flex: 1 1 auto;
749
+ align-items: center;
750
+ gap: 10px;
751
+ min-width: 0;
752
+ min-height: 44px;
753
+ padding: 7px 10px;
754
+ border: 1px solid var(--dsw-alias-border-l1);
755
+ border-radius: 12px;
756
+ background: var(--dsw-alias-bg-layer-2);
757
+ color: var(--dsw-alias-label-primary);
758
+ font: inherit;
759
+ text-align: left;
760
+ cursor: pointer;
761
+ touch-action: manipulation;
762
+ overflow-wrap: anywhere;
763
+ }
764
+ .dsh-wp-collapse-toggle:hover { background: var(--dsw-alias-bg-layer-1); }
765
+ .dsh-wp-chart-more { margin-top: 6px; font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-primary); }
766
+ .dsh-wp-collapse-toggle:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
767
+ .dsh-wp-collapse-chevron {
768
+ flex: 0 0 auto;
769
+ width: 9px;
770
+ height: 9px;
771
+ margin-left: 2px;
772
+ border-right: 2px solid currentColor;
773
+ border-bottom: 2px solid currentColor;
774
+ transform: rotate(-45deg);
775
+ transition: transform 160ms ease;
776
+ }
777
+ .dsh-wp-collapse[data-open="true"] .dsh-wp-collapse-chevron { transform: rotate(45deg); }
778
+ .dsh-wp-collapse-summary { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
779
+ .dsh-wp-collapse-title { font-weight: 650; min-width: 0; }
780
+ .dsh-wp-collapse-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
781
+ .dsh-wp-collapse-badge { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; margin-left: auto; }
782
+ .dsh-wp-collapse-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
783
+ .dsh-wp-collapse-panel { margin-top: 10px; min-width: 0; }
784
+ .dsh-wp-collapse-panel[hidden] { display: none; }
785
+ /* Task card / step card wrapping: the disclosure row is the only content shown
786
+ while collapsed, so the card keeps its tone border and padding. */
787
+ .dsh-wp-session-collapse .dsh-wp-collapse-toggle { padding: 9px 12px; }
788
+ .dsh-wp-session-updated { margin: 0 0 10px; }
789
+ .dsh-wp-stage-collapse .dsh-wp-collapse-toggle { padding: 7px 10px; }
790
+ .dsh-wp-stage-body > .dsh-wp-stage-goal { margin-top: 0; }
791
+ .dsh-wp-details { margin-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; }
792
+ .dsh-wp-details > summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; font-weight: 600; color: var(--dsw-alias-label-primary); }
793
+ .dsh-wp-details > summary:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
794
+ .dsh-wp-turn {
795
+ margin: 8px 0;
796
+ border: 1px solid var(--dsw-alias-border-l1);
797
+ border-left: 3px solid var(--dsw-alias-brand-primary);
798
+ border-radius: 10px;
799
+ background: var(--dsw-alias-bg-layer-1);
800
+ padding: 10px 12px;
801
+ color: var(--dsw-alias-label-primary);
802
+ min-width: 0;
803
+ overflow-wrap: anywhere;
804
+ }
805
+ .dsh-wp-turn.dsh-wp-tone-done { border-left-color: var(--dsh-wp-done); }
806
+ .dsh-wp-turn.dsh-wp-tone-active { border-left-color: var(--dsh-wp-active); }
807
+ .dsh-wp-turn-title { font-weight: 650; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
808
+ .dsh-wp-tool {
809
+ border: 1px solid var(--dsw-alias-border-l1);
810
+ border-radius: 10px;
811
+ background: var(--dsw-alias-bg-layer-1);
812
+ padding: 8px 10px;
813
+ margin: 6px 0;
814
+ min-width: 0;
815
+ overflow-wrap: anywhere;
816
+ }
817
+ .dsh-wp-tool-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-weight: 600; }
818
+ .dsh-wp-tool-body { margin-top: 4px; color: var(--dsw-alias-label-secondary); font-size: 12px; }
819
+ .dsh-wp-code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
820
+ .dsh-wp-sidebar-icon { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: currentColor; }
821
+ @media (max-width: 600px) {
822
+ .dsh-wp { padding: 12px; padding-bottom: calc(20px + env(safe-area-inset-bottom)); }
823
+ .dsh-wp-sessions { grid-template-columns: minmax(0, 1fr); }
824
+ .dsh-wp-overview { grid-template-columns: minmax(0, 1fr); }
825
+ .dsh-wp-statbar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
826
+ .dsh-wp-stat:nth-child(3),
827
+ .dsh-wp-stat:nth-child(4),
828
+ .dsh-wp-stat:nth-child(5) { border-top: 1px solid var(--dsw-alias-border-l1); }
829
+ .dsh-wp-stat:nth-child(3),
830
+ .dsh-wp-stat:nth-child(5) { border-left: 0; }
831
+ .dsh-wp-stat:nth-child(5) { grid-column: 1 / -1; }
832
+ .dsh-wp-session { padding: 12px; }
833
+ .dsh-wp-guide-body { grid-template-columns: minmax(0, 1fr); gap: 6px; }
834
+ .dsh-wp-details > summary { min-height: 44px; }
835
+ }
836
+ @media (prefers-reduced-motion: reduce) {
837
+ .dsh-wp-progress-fill { transition: none; }
838
+ .dsh-wp-collapse-chevron { transition: none; }
839
+ }
840
+
841
+ /* 主题自适应文本色(渐变品牌色 → 与主文本色混合)。
842
+ 浅色 #b35616 4.93:1 / #0e79a9 4.85:1,深色 #f99c5b 7.82:1 / #55beee 7.86:1。*/
843
+ @supports (color: color-mix(in srgb, red, blue)) {
844
+ .dsh-wp {
845
+ --dsh-wp-done-text: color-mix(in srgb, var(--dsh-wp-done) 70%, var(--dsw-alias-label-primary));
846
+ --dsh-wp-active-text: color-mix(in srgb, var(--dsh-wp-active) 70%, var(--dsw-alias-label-primary));
847
+ }
848
+ }
849
+ `;
850
+ function injectStyles() {
851
+ const id = 'dsh-workplan-styles';
852
+ const existing = document.getElementById(id);
853
+ if (existing)
854
+ existing.remove();
855
+ const style = document.createElement('style');
856
+ style.id = id;
857
+ style.textContent = CSS;
858
+ document.head.appendChild(style);
859
+ return () => style.remove();
860
+ }
861
+ function asText(value) {
862
+ return typeof value === 'string' ? value : '';
863
+ }
864
+ function clampPct(value) {
865
+ const number = Number(value);
866
+ return Number.isFinite(number) ? Math.max(0, Math.min(100, Math.round(number))) : 0;
867
+ }
868
+ function formatTime(value) {
869
+ if (!value)
870
+ return '';
871
+ const date = new Date(value);
872
+ if (Number.isNaN(date.getTime()))
873
+ return '';
874
+ return date.toLocaleString();
875
+ }
876
+ function toneOfStatus(status) {
877
+ if (status === 'done')
878
+ return 'done';
879
+ if (status === 'in_progress')
880
+ return 'active';
881
+ if (status === 'blocked')
882
+ return 'blocked';
883
+ return 'idle';
884
+ }
885
+ function phaseTone(phase) {
886
+ if (phase === 'blocked')
887
+ return 'blocked';
888
+ if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused')
889
+ return 'warn';
890
+ if (phase === 'integrating' || phase === 'done')
891
+ return 'done';
892
+ if (phase === 'executing' || phase === 'planning')
893
+ return 'active';
894
+ return 'idle';
895
+ }
896
+ function allEntries(session) {
897
+ const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item) => [item.subtaskId, item]));
898
+ const entries = [];
899
+ for (const stage of Array.isArray(session?.stages) ? session.stages : []) {
900
+ for (const subtask of Array.isArray(stage?.subtasks) ? stage.subtasks : []) {
901
+ entries.push({ stage, subtask, item: byId.get(subtask.id) });
902
+ }
903
+ }
904
+ return entries;
905
+ }
906
+ function findEntry(session, status) {
907
+ return allEntries(session).find((entry) => entry.item?.status === status) || null;
908
+ }
909
+ function deptName(t, id) {
910
+ const key = `dept.${id}`;
911
+ const value = t(key);
912
+ return value === key ? String(id || '') : value;
913
+ }
914
+ function levelText(t, value) {
915
+ const key = `level.${value}`;
916
+ const text = t(key);
917
+ return text === key ? String(value || '') : text;
918
+ }
919
+ function stageLabel(t, index) {
920
+ return `${t('stepPrefix')} ${index + 1} ${t('stepSuffix')}`.trim();
921
+ }
922
+ function workLabel(t, index) {
923
+ return `${t('workPrefix')} ${index + 1}`;
924
+ }
925
+ function plainError(value) {
926
+ const text = typeof value === 'string' ? value : '';
927
+ if (!text)
928
+ return '';
929
+ if (text.includes('超时'))
930
+ return '这件事花的时间太长,系统先停下来了。';
931
+ if (text.includes('上游') || text.includes('依赖'))
932
+ return '前面的工作还没做完,所以这一步暂时不能开始。';
933
+ if (text.includes('未启动'))
934
+ return '这次没有启动负责这件事的 Agent。';
935
+ if (text.includes('未完成'))
936
+ return '这件事没有做完。';
937
+ return text;
938
+ }
939
+ function workerText(session, t) {
940
+ const active = findEntry(session, 'in_progress');
941
+ if (active) {
942
+ return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
943
+ }
944
+ const blocked = findEntry(session, 'blocked');
945
+ if (blocked) {
946
+ return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`;
947
+ }
948
+ const stats = session.stats || {};
949
+ if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
950
+ return t('helpDone');
951
+ return t('helpNobody');
952
+ }
953
+ /*
954
+ * A blocked card used to read "卡住了" and stop there: no hint that the plan
955
+ * is recoverable, so the only visible option was to restate the request and
956
+ * build a fresh plan. Say out loud how to resume instead.
957
+ */
958
+ function resumeHint(session, t) {
959
+ if (!session || session.phase !== 'blocked')
960
+ return '';
961
+ if (session.resumeAvailable) {
962
+ const limit = Number(session.resumeLimit ?? 0);
963
+ const used = Number(session.resumeCount ?? 0);
964
+ const left = Number.isFinite(limit) && limit > 0 ? Math.max(0, limit - used) : 0;
965
+ const suffix = left > 0 ? `(${t('resumeLeft')} ${left} ${t('resumeTimes')})` : '';
966
+ return `;${t('resumeReady')}${suffix}`;
967
+ }
968
+ return `;${t('resumeExhausted')}`;
969
+ }
970
+ function nextText(session, t) {
971
+ const blocked = findEntry(session, 'blocked');
972
+ if (blocked)
973
+ return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`;
974
+ const next = findEntry(session, 'pending') || allEntries(session).find((entry) => !entry.item);
975
+ if (next)
976
+ return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`;
977
+ const stats = session.stats || {};
978
+ if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
979
+ return t('helpDone');
980
+ return t('helpNextDone');
981
+ }
982
+ function progressText(item, t) {
983
+ const status = item?.status || 'pending';
984
+ if (status === 'done')
985
+ return t('status.done');
986
+ if (status === 'blocked')
987
+ return t('status.blocked');
988
+ if (status === 'in_progress')
989
+ return t('status.in_progress');
990
+ return t('status.pending');
991
+ }
992
+ function Bullets({ items }) {
993
+ const list = (Array.isArray(items) ? items : []).filter((item) => item !== null && item !== undefined && String(item).trim() !== '');
994
+ if (list.length === 0)
995
+ return h('span', { className: 'dsh-wp-muted' }, '—');
996
+ return h('ul', { className: 'dsh-wp-list' }, list.map((item, index) => h('li', { key: index }, String(item))));
997
+ }
998
+ function Guide({ session, t }) {
999
+ const task = session.taskUnderstanding || {};
1000
+ const what = task.deliverable || session.title || t('panel');
1001
+ return h('section', { className: 'dsh-wp-guide', 'aria-label': t('helpTitle') }, h('div', { className: 'dsh-wp-guide-title' }, t('helpTitle')), h('div', { className: 'dsh-wp-guide-body' }, h('div', { className: 'dsh-wp-guide-row' }, h('div', { className: 'dsh-wp-guide-label' }, t('helpWhat')), h('div', { className: 'dsh-wp-guide-value' }, what)), h('div', { className: 'dsh-wp-guide-row' }, h('div', { className: 'dsh-wp-guide-label' }, t('helpWho')), h('div', { className: 'dsh-wp-guide-value' }, workerText(session, t))), h('div', { className: 'dsh-wp-guide-row' }, h('div', { className: 'dsh-wp-guide-label' }, t('helpNext')), h('div', { className: 'dsh-wp-guide-value' }, nextText(session, t)))), h('div', { className: 'dsh-wp-guide-hint' }, t('helpHint')));
1002
+ }
1003
+ function Pill({ children, tone = 'idle' }) {
1004
+ return h('span', { className: `dsh-wp-pill dsh-wp-tone-${tone}` }, h('span', { className: 'dsh-wp-dot' }), children);
1005
+ }
1006
+ function Progress({ value, tone = 'active' }) {
1007
+ const pct = clampPct(value);
1008
+ return h('div', { className: 'dsh-wp-progress', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': pct }, h('div', {
1009
+ className: `dsh-wp-progress-fill${tone === 'done' ? ' dsh-wp-progress-fill--done' : tone === 'blocked' ? ' dsh-wp-progress-fill--blocked' : ''}`,
1010
+ style: { width: `${pct}%` },
1011
+ }));
1012
+ }
1013
+ function List({ items, empty }) {
1014
+ if (!Array.isArray(items) || items.length === 0) {
1015
+ return h('span', { className: 'dsh-wp-muted' }, empty || '—');
1016
+ }
1017
+ return h('ul', { className: 'dsh-wp-list' }, items.map((item, index) => h('li', { key: index }, String(item))));
1018
+ }
1019
+ function Field({ label, children }) {
1020
+ return h('div', { className: 'dsh-wp-field' }, h('div', { className: 'dsh-wp-field-label' }, label), h('div', { className: 'dsh-wp-field-value' }, children));
1021
+ }
1022
+ let collapseIdSeq = 0;
1023
+ function nextCollapseId() {
1024
+ collapseIdSeq += 1;
1025
+ return `dsh-wp-collapse-${collapseIdSeq}`;
1026
+ }
1027
+ /**
1028
+ * Collapse — the one disclosure container shared by the task layer
1029
+ * (a task card) and the step layer (the "分几步做" list).
1030
+ *
1031
+ * Behaviour:
1032
+ * - collapsed by default (`defaultOpen` defaults to false);
1033
+ * - the summary row is a native <button>, so a click, Enter, or Space all
1034
+ * activate the same toggle and `aria-expanded` mirrors the state;
1035
+ * - uncontrolled (`defaultOpen`) or controlled (`open` + `onOpenChange`);
1036
+ * - summary clicks/Enter/Space stop propagation, so toggling never triggers
1037
+ * a clickable parent card; content inside the panel keeps normal bubbling,
1038
+ * so links and buttons in the body still work;
1039
+ * - the collapsed summary keeps `title`, `meta` (e.g. "1/4 做好了") and
1040
+ * `badge` (e.g. the phase pill) visible; `actions` stays outside the
1041
+ * toggle button for extra controls.
1042
+ *
1043
+ * Usage (uncontrolled, collapsed until the user opens it):
1044
+ * h(Collapse, {
1045
+ * title: session.title,
1046
+ * meta: `${stats.done}/${stats.total} ${t('stats.done')}`,
1047
+ * badge: h(Pill, { tone }, t(`phase.${phase}`)),
1048
+ * }, h('div', null, body))
1049
+ *
1050
+ * Usage (controlled):
1051
+ * h(Collapse, {
1052
+ * open: expanded,
1053
+ * onOpenChange: setExpanded,
1054
+ * title: stage.name,
1055
+ * }, h('div', null, stageBody))
1056
+ */
1057
+ function Collapse(props) {
1058
+ const { title, meta, badge, actions, children, defaultOpen = false, open: openProp, onOpenChange, id, className, panelClassName, } = props || {};
1059
+ const [generatedId] = useState(() => nextCollapseId());
1060
+ const baseId = asText(id) || generatedId;
1061
+ const panelId = `${baseId}-panel`;
1062
+ const controlled = typeof openProp === 'boolean';
1063
+ const [innerOpen, setInnerOpen] = useState(defaultOpen === true);
1064
+ const open = controlled ? openProp : innerOpen;
1065
+ const toggle = useCallback(() => {
1066
+ const next = !open;
1067
+ if (!controlled)
1068
+ setInnerOpen(next);
1069
+ if (typeof onOpenChange === 'function')
1070
+ onOpenChange(next);
1071
+ }, [controlled, open, onOpenChange]);
1072
+ const stop = (event) => {
1073
+ if (event && typeof event.stopPropagation === 'function')
1074
+ event.stopPropagation();
1075
+ };
1076
+ const handleClick = (event) => {
1077
+ stop(event);
1078
+ toggle();
1079
+ };
1080
+ const handleKeyDown = (event) => {
1081
+ const key = event?.key;
1082
+ // The native button already turns Enter/Space into a click; only stop
1083
+ // the key event from reaching clickable parents here.
1084
+ if (key === 'Enter' || key === ' ' || key === 'Spacebar')
1085
+ stop(event);
1086
+ };
1087
+ const extra = asText(className);
1088
+ return h('section', {
1089
+ className: `dsh-wp-collapse${open ? ' dsh-wp-collapse--open' : ''}${extra ? ` ${extra}` : ''}`,
1090
+ 'data-open': open ? 'true' : 'false',
1091
+ }, h('div', { className: 'dsh-wp-collapse-head' }, h('button', {
1092
+ type: 'button',
1093
+ className: 'dsh-wp-collapse-toggle',
1094
+ 'aria-expanded': open,
1095
+ 'aria-controls': panelId,
1096
+ onClick: handleClick,
1097
+ onKeyDown: handleKeyDown,
1098
+ }, h('span', { className: 'dsh-wp-collapse-chevron', 'aria-hidden': 'true' }), h('span', { className: 'dsh-wp-collapse-summary' }, h('span', { className: 'dsh-wp-collapse-title' }, title ?? ''), meta === null || meta === undefined || meta === false
1099
+ ? null
1100
+ : h('span', { className: 'dsh-wp-collapse-meta' }, meta)), badge === null || badge === undefined || badge === false
1101
+ ? null
1102
+ : h('span', { className: 'dsh-wp-collapse-badge' }, badge)), actions ? h('div', { className: 'dsh-wp-collapse-actions' }, actions) : null), h('div', {
1103
+ id: panelId,
1104
+ className: `dsh-wp-collapse-panel${asText(panelClassName) ? ` ${panelClassName}` : ''}`,
1105
+ hidden: !open,
1106
+ }, children));
1107
+ }
1108
+ async function fetchPlans(sessionId, signal) {
1109
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
1110
+ const response = await fetch(`/dsh-workplan/plans${query}`, {
1111
+ headers: { accept: 'application/json' },
1112
+ cache: 'no-store',
1113
+ signal,
1114
+ });
1115
+ if (!response.ok)
1116
+ throw new Error(`HTTP ${response.status}`);
1117
+ const payload = await response.json();
1118
+ if (!payload || payload.ok !== true) {
1119
+ throw new Error(asText(payload?.error) || '计划接口返回异常');
1120
+ }
1121
+ return Array.isArray(payload.sessions) ? payload.sessions : [];
1122
+ }
1123
+ function usePlans(sessionId) {
1124
+ const [state, setState] = useState({ loading: true, sessions: [], error: null, updatedAt: null });
1125
+ const load = useCallback(async (signal) => {
1126
+ try {
1127
+ const sessions = await fetchPlans(sessionId, signal);
1128
+ if (signal?.aborted)
1129
+ return;
1130
+ setState({ loading: false, sessions, error: null, updatedAt: Date.now() });
1131
+ }
1132
+ catch (error) {
1133
+ if (signal?.aborted)
1134
+ return;
1135
+ setState((previous) => ({
1136
+ ...previous,
1137
+ loading: false,
1138
+ error: error instanceof Error ? error.message : String(error),
1139
+ }));
1140
+ }
1141
+ }, [sessionId]);
1142
+ useEffect(() => {
1143
+ const controller = new AbortController();
1144
+ load(controller.signal);
1145
+ const timer = setInterval(() => load(controller.signal), 4000);
1146
+ return () => {
1147
+ controller.abort();
1148
+ clearInterval(timer);
1149
+ };
1150
+ }, [load]);
1151
+ return { ...state, reload: () => load() };
1152
+ }
1153
+ function ErrorBanner({ message, onRetry, t }) {
1154
+ return h('div', { className: 'dsh-wp-banner dsh-wp-banner--error', role: 'alert' }, h('div', { className: 'dsh-wp-section-title' }, t('error')), h('div', { className: 'dsh-wp-error-text' }, message || t('noResult')), h('button', { type: 'button', className: 'dsh-wp-button', style: { marginTop: '8px' }, onClick: onRetry }, t('retry')));
1155
+ }
1156
+ function SubtaskRow({ item, subtask, index, t }) {
1157
+ const status = item?.status ?? 'pending';
1158
+ const tone = toneOfStatus(status);
1159
+ const pct = clampPct(item?.progress ?? 0);
1160
+ return h('div', { className: `dsh-wp-subtask dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-subtask-head' }, h('div', { className: 'dsh-wp-subtask-title' }, `${workLabel(t, index)}:${subtask.title}`), h(Pill, { tone }, progressText(item, t))), h('div', { className: 'dsh-wp-subtask-meta' }, h('span', null, `${t('who')}:${deptName(t, subtask.department)} · ${subtask.role}`)), h('div', { className: 'dsh-wp-subtask-body' }, h('p', null, h('strong', null, `${t('what')}:`), subtask.objective), h('div', null, h('strong', null, `${t('how')}:`), h(Bullets, { items: subtask.method })), h('div', null, h('strong', null, `${t('doneWhen')}:`), h(Bullets, { items: subtask.acceptance })), subtask.outputs?.length ? h('div', null, h('strong', null, `${t('willDeliver')}:`), h(Bullets, { items: subtask.outputs })) : null, subtask.dependsOn?.length ? h('p', { className: 'dsh-wp-muted' }, `${t('waitFor')}:先等前面的工作做完,才能开始这一步。`) : null), status === 'in_progress' ? h(Progress, { value: pct, tone }) : null, status === 'in_progress' ? h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${t('progressLabel')} ${pct}%`)) : null, status !== 'blocked' && item?.summary ? h('p', { className: 'dsh-wp-muted' }, `${t('summary')}:${plainError(item.summary)}`) : null, item?.error ? h('p', { className: 'dsh-wp-error-text' }, `${t('blockedReason')}:${plainError(item.error)}`) : null, h('details', { className: 'dsh-wp-details' }, h('summary', null, t('more')), h('div', { className: 'dsh-wp-grid', style: { marginTop: '8px' } }, h(Field, { label: t('person') }, subtask.role || '—'), h(Field, { label: t('inputs') }, h(Bullets, { items: subtask.inputs })), h(Field, { label: t('risk') }, subtask.risk || '—'), h(Field, { label: t('planId') }, subtask.id || '—'))));
1161
+ }
1162
+ function StageCard({ stage, statuses, index, t }) {
1163
+ const byId = useMemo(() => new Map((statuses || []).map((item) => [item.subtaskId, item])), [statuses]);
1164
+ const items = (stage.subtasks || []).map((subtask) => ({ subtask, item: byId.get(subtask.id) }));
1165
+ const done = items.filter(({ item }) => item?.status === 'done').length;
1166
+ const blocked = items.filter(({ item }) => item?.status === 'blocked').length;
1167
+ const inProgress = items.some(({ item }) => item?.status === 'in_progress');
1168
+ const tone = blocked > 0 ? 'blocked' : done === items.length ? 'done' : inProgress ? 'active' : 'idle';
1169
+ // Same tone/counts as before; only the presentation moved into the
1170
+ // collapsed summary row (done/total + state pill), so the numbers keep one
1171
+ // source: this step's own items from `statuses`.
1172
+ const stateText = blocked > 0
1173
+ ? `${blocked} ${t('stats.blocked')}`
1174
+ : done === items.length
1175
+ ? t('helpDone')
1176
+ : inProgress ? t('phase.executing') : t('phase.pending');
1177
+ return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` }, h(Collapse, {
1178
+ className: 'dsh-wp-stage-collapse',
1179
+ panelClassName: 'dsh-wp-stage-body',
1180
+ title: `${stageLabel(t, index)}:${stage.name}`,
1181
+ meta: `${done}/${items.length} ${t('stats.done')}`,
1182
+ badge: h(Pill, { tone }, stateText),
1183
+ }, h('div', { className: 'dsh-wp-stage-goal' }, `${t('what')}:${stage.goal}`), h('div', { className: 'dsh-wp-checkpoint' }, `${t('checkpoint')}:${stage.checkpoint}`), items.map(({ subtask, item }, subIndex) => h(SubtaskRow, { key: subtask.id, subtask, item, index: subIndex, t }))));
1184
+ }
1185
+ function Analysis({ session, t }) {
1186
+ const task = session.taskUnderstanding || {};
1187
+ const contradictions = session.contradictions || {};
1188
+ const primary = contradictions.primary || {};
1189
+ 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
+ }
1191
+ function SessionCard({ session, t, open, onToggle }) {
1192
+ const domId = `dsh-wp-session-${session.sessionId}`;
1193
+ const phase = session.phase || 'pending';
1194
+ const stats = session.stats || {};
1195
+ const tone = phaseTone(phase);
1196
+ 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;
1203
+ return h('article', { id: domId, 'data-session-id': session.sessionId, className: `dsh-wp-session dsh-wp-tone-${tone}` }, h(Collapse, {
1204
+ id: `dsh-wp-collapse-${session.sessionId}`,
1205
+ open: open === true,
1206
+ onOpenChange: onToggle,
1207
+ className: 'dsh-wp-session-collapse',
1208
+ panelClassName: 'dsh-wp-session-body',
1209
+ 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
+ ],
1215
+ badge: h(Pill, { tone }, t(`phase.${phase}`)),
1216
+ }, session.updatedAt
1217
+ ? 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, {
1219
+ key: stage.id,
1220
+ stage,
1221
+ statuses: session.statuses || [],
1222
+ index,
1223
+ t,
1224
+ }))), Analysis({ session, t }), Array.isArray(session.errors) && session.errors.length
1225
+ ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('error')), h('div', { className: 'dsh-wp-error-text' }, session.errors.map(plainError).join(';')))
1226
+ : null));
1227
+ }
1228
+ const CHART_URL = '/dsh-workplan/vendor/chart.umd.js';
1229
+ let chartLibPromise = null;
1230
+ function loadChartLib() {
1231
+ if (chartLibPromise)
1232
+ return chartLibPromise;
1233
+ chartLibPromise = new Promise((resolve) => {
1234
+ const scope = typeof window === 'undefined' ? null : window;
1235
+ if (!scope || !scope.document) {
1236
+ resolve(null);
1237
+ return;
1238
+ }
1239
+ if (scope.Chart) {
1240
+ resolve(scope.Chart);
1241
+ return;
1242
+ }
1243
+ const script = scope.document.createElement('script');
1244
+ script.src = CHART_URL;
1245
+ script.async = true;
1246
+ script.onload = () => resolve(scope.Chart || null);
1247
+ script.onerror = () => resolve(null);
1248
+ scope.document.head.appendChild(script);
1249
+ });
1250
+ return chartLibPromise;
1251
+ }
1252
+ function GroupCharts({ liveCount, doneCount, liveBars, hiddenBars, t }) {
1253
+ // 用「状态当 ref」:宿主注入的 React 兼容层不保证有 useRef(实测会抛
1254
+ // useRef is not a function),而 useState 一定可用;节点变化才更新,
1255
+ // 避免 ref 回调触发无意义的重渲染循环。
1256
+ const [wrapEl, setWrapEl] = useState(null);
1257
+ // 'fallback' renders a pure-CSS split bar from the first paint, so the
1258
+ // card is never an empty canvas while Chart.js is still loading (or when
1259
+ // the script is unavailable, e.g. the host has not been restarted yet).
1260
+ const [chartState, setChartState] = useState('fallback');
1261
+ const barKey = JSON.stringify(liveBars || []);
1262
+ // 主题切换要重建图表:文字色 ink 只在建图那一刻从 body 读一次,不重建就会
1263
+ // 一直用旧主题的颜色(深色底上压深色字)。主题包通过 body 上的
1264
+ // data-ds-dark-theme 属性切换,监听它即可,不引入任何依赖。
1265
+ const [themeTick, setThemeTick] = useState(0);
1266
+ useEffect(() => {
1267
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined')
1268
+ return;
1269
+ const observer = new MutationObserver(() => setThemeTick((n) => n + 1));
1270
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] });
1271
+ return () => observer.disconnect();
1272
+ }, []);
1273
+ useEffect(() => {
1274
+ let dropped = false;
1275
+ const charts = [];
1276
+ const reduced = typeof window !== 'undefined' && window.matchMedia
1277
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1278
+ loadChartLib().then((lib) => {
1279
+ if (dropped || !lib || !wrapEl)
1280
+ return;
1281
+ const host = wrapEl;
1282
+ // --dsw-alias-* 由主题包定义在 <body> 上,<html> 读不到自定义属性时会得到 '',
1283
+ // 于是恒回退 #e2e8f0:浅色主题下对白底只有 1.14:1,图例与坐标轴几乎不可见
1284
+ // (T4 实测)。改读 body 后浅色 17.43:1 / 深色 15.01:1。
1285
+ const ink = getComputedStyle(document.body).getPropertyValue('--dsw-alias-label-primary').trim() || '#e2e8f0';
1286
+ const canvasA = host.querySelector('canvas[data-chart="groups"]');
1287
+ const canvasB = host.querySelector('canvas[data-chart="live"]');
1288
+ try {
1289
+ if (canvasA && (liveCount + doneCount) > 0) {
1290
+ const ctx = canvasA.getContext('2d');
1291
+ const liveFill = ctx.createLinearGradient(0, 0, 0, 210);
1292
+ liveFill.addColorStop(0, '#fdba74');
1293
+ liveFill.addColorStop(1, '#ea580c');
1294
+ const doneFill = ctx.createLinearGradient(0, 0, 0, 210);
1295
+ doneFill.addColorStop(0, '#7dd3fc');
1296
+ doneFill.addColorStop(1, '#0369a1');
1297
+ charts.push(new lib(canvasA, {
1298
+ type: 'doughnut',
1299
+ data: {
1300
+ labels: [t('groupLive'), t('groupDone')],
1301
+ datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
1302
+ },
1303
+ options: {
1304
+ responsive: true, maintainAspectRatio: false, cutout: '66%',
1305
+ animation: reduced ? false : { duration: 720, easing: 'easeOutQuart' },
1306
+ plugins: {
1307
+ legend: { position: 'bottom', labels: { color: ink, usePointStyle: true, pointStyle: 'circle', boxWidth: 9, boxHeight: 9, padding: 14 } },
1308
+ tooltip: { callbacks: { label: (item) => ` ${item.label}: ${item.parsed}` } },
1309
+ },
1310
+ },
1311
+ }));
1312
+ }
1313
+ if (canvasB && liveBars.length) {
1314
+ charts.push(new lib(canvasB, {
1315
+ type: 'bar',
1316
+ data: {
1317
+ labels: liveBars.map((bar) => bar.label),
1318
+ datasets: [{ data: liveBars.map((bar) => bar.value), backgroundColor: 'rgba(249,115,22,.85)', borderRadius: 6, barThickness: 15 }],
1319
+ },
1320
+ options: {
1321
+ indexAxis: 'y', responsive: true, maintainAspectRatio: false,
1322
+ animation: reduced ? false : { duration: 720, easing: 'easeOutQuart' },
1323
+ scales: {
1324
+ x: { min: 0, max: 100, ticks: { color: ink, callback: (value) => `${value}%` }, grid: { color: 'rgba(148,163,184,.18)' } },
1325
+ y: { ticks: { color: ink, font: { size: 11 } }, grid: { display: false } },
1326
+ },
1327
+ plugins: { legend: { display: false }, tooltip: { callbacks: { label: (item) => ` ${item.parsed.x}% ${t('stats.done')}` } } },
1328
+ },
1329
+ }));
1330
+ }
1331
+ }
1332
+ catch (error) {
1333
+ /* Charts are decoration: a canvas failure must never break the panel. */
1334
+ }
1335
+ if (!dropped && charts.length)
1336
+ setChartState('ready');
1337
+ });
1338
+ return () => {
1339
+ dropped = true;
1340
+ for (const chart of charts) {
1341
+ try {
1342
+ chart.destroy();
1343
+ }
1344
+ catch (error) { /* ignore */ }
1345
+ }
1346
+ };
1347
+ }, [liveCount, doneCount, barKey, t, wrapEl, themeTick]);
1348
+ const sum = liveCount + doneCount;
1349
+ const livePct = sum > 0 ? (liveCount / sum) * 100 : 0;
1350
+ const donePct = sum > 0 ? (doneCount / sum) * 100 : 0;
1351
+ return h('div', {
1352
+ className: `dsh-wp-charts dsh-wp-charts--${chartState}`,
1353
+ ref: (node) => { if (node !== wrapEl)
1354
+ setWrapEl(node); },
1355
+ }, h('div', { className: 'dsh-wp-chart-card' }, h('div', { className: 'dsh-wp-chart-title' }, t('chartGroups')),
1356
+ // canvas 必须始终在 DOM 里:图表是先有 canvas 才能建,建完才把
1357
+ // chartState 置 ready。若用 chartState 门控 canvas,就形成
1358
+ // 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
1359
+ // 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
1360
+ // fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
1361
+ h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` })), chartState !== 'ready'
1362
+ ? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('groupLive')} ${liveCount} / ${t('groupDone')} ${doneCount}` }, h('span', { className: 'dsh-wp-fallback-live', style: { width: `${livePct}%` } }), h('span', { className: 'dsh-wp-fallback-done', style: { width: `${donePct}%` } })), h('div', { className: 'dsh-wp-fallback-legend' }, h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--live' }), `${t('groupLive')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('groupDone')} ${doneCount}`)))
1363
+ : null), (liveBars || []).length
1364
+ ? h('div', { className: 'dsh-wp-chart-card' }, h('div', { className: 'dsh-wp-chart-title' }, t('chartLive')), h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'live', role: 'img', 'aria-label': t('chartLive') })), hiddenBars > 0 ? h('div', { className: 'dsh-wp-chart-more' }, fill(t('chartMore'), { n: hiddenBars })) : null)
1365
+ : null);
1366
+ }
1367
+ /**
1368
+ * 极简 `{name}` 插值:语序留在 locale 里(中文/英文词序不同),代码只负责替换。
1369
+ * 未提供的占位符原样保留——宁可露出 `{pct}`,也不渲染出 undefined。
1370
+ */
1371
+ function fill(template, vars) {
1372
+ return String(template).replace(/\{(\w+)\}/g, (match, key) => {
1373
+ if (!vars || !(key in vars))
1374
+ return match;
1375
+ const value = vars[key];
1376
+ return value === null || value === undefined ? match : String(value);
1377
+ });
1378
+ }
1379
+ /**
1380
+ * 组头完成度聚合——这是 T6 把「一眼看清」变成可测试属性的口径。
1381
+ *
1382
+ * 只读 T1 已确认的 `session.stats`,不新增接口、不改下发格式:
1383
+ * - total = Σ stats.total,负数与非有限值按 0 计(脏数据不污染分母)
1384
+ * - done = Σ stats.done,再夹到 [0, total](done > total 的脏数据不会显示 >100%)
1385
+ * - open = total - done,所以「进行中 N / 已完成 M」两数相加恒等于 total
1386
+ * - percent = total > 0 ? round(done / total * 100) : null
1387
+ * total 为 0(还没拆出子任务、或阶段处于 degraded)时返回 null,
1388
+ * 由文案层输出「完成率暂无数据」,因此永远不会出现 NaN / Infinity。
1389
+ * - active/pending/blocked 只用于 aria 明细,不参与分组判定。
1390
+ */
1391
+ function groupTotals(list) {
1392
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
1393
+ let total = 0;
1394
+ let done = 0;
1395
+ let active = 0;
1396
+ let pending = 0;
1397
+ let blocked = 0;
1398
+ for (const session of Array.isArray(list) ? list : []) {
1399
+ const stats = (session && session.stats) || {};
1400
+ total += num(stats.total);
1401
+ done += num(stats.done);
1402
+ active += num(stats.inProgress);
1403
+ pending += num(stats.pending);
1404
+ blocked += num(stats.blocked);
1405
+ }
1406
+ total = Math.max(0, total);
1407
+ done = Math.min(total, Math.max(0, done));
1408
+ return {
1409
+ total,
1410
+ done,
1411
+ open: total - done,
1412
+ percent: total > 0 ? Math.round((done / total) * 100) : null,
1413
+ active: Math.max(0, active),
1414
+ pending: Math.max(0, pending),
1415
+ blocked: Math.max(0, blocked),
1416
+ };
1417
+ }
1418
+ /**
1419
+ * 零依赖完成度环:两笔内联 SVG 圆(底槽 + 弧),不引第三方库、不用 canvas。
1420
+ * 纯装饰(aria-hidden)——百分比同时以纯文本出现在摘要行里,
1421
+ * 所以色觉障碍、对比度不足、甚至关掉图形时都仍可读出进度(WCAG 1.4.1 冗余编码)。
1422
+ * 弧线色 #0284c7:浅色 #fafafb 上 3.93:1、深色 #1b1c1f 上 4.16:1,均 ≥3:1 图形对比。
1423
+ * 0% 不画弧(避免圆头端点在 0 处留下一个小点),中心仍写 0%;未知写「—」。
1424
+ */
1425
+ function GroupRing({ percent }) {
1426
+ const size = 44;
1427
+ const radius = 18;
1428
+ const stroke = 4;
1429
+ const circumference = 2 * Math.PI * radius;
1430
+ const value = typeof percent === 'number' && Number.isFinite(percent)
1431
+ ? Math.min(100, Math.max(0, percent))
1432
+ : null;
1433
+ 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', {
1435
+ className: 'dsh-wp-group-ring-track',
1436
+ cx: size / 2,
1437
+ cy: size / 2,
1438
+ r: radius,
1439
+ fill: 'none',
1440
+ strokeWidth: stroke,
1441
+ }), value !== null && value > 0
1442
+ ? h('circle', {
1443
+ className: 'dsh-wp-group-ring-arc',
1444
+ cx: size / 2,
1445
+ cy: size / 2,
1446
+ r: radius,
1447
+ fill: 'none',
1448
+ strokeWidth: stroke,
1449
+ strokeLinecap: 'round',
1450
+ strokeDasharray: `${arc} ${circumference - arc}`,
1451
+ transform: `rotate(-90 ${size / 2} ${size / 2})`,
1452
+ })
1453
+ : null), h('span', { className: 'dsh-wp-group-ring-label' }, value === null ? '—' : `${value}%`));
1454
+ }
1455
+ function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }) {
1456
+ const isDone = group === 'done';
1457
+ // 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
1458
+ // 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
1459
+ // 折叠态由本组件自己持有(不给 users 存偏好),刷新后回到默认,符合 §7.3「不打扰」。
1460
+ const [open, setOpen] = useState(!(isDone && hasLive));
1461
+ // §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
1462
+ // ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
1463
+ // 依赖都是布尔值,轮询刷新不会重复触发。
1464
+ const holdsOpenCard = list.some((session) => session.sessionId === openSessionId);
1465
+ useEffect(() => { if (!hasLive)
1466
+ setOpen(true); }, [hasLive]);
1467
+ useEffect(() => { if (holdsOpenCard)
1468
+ setOpen(true); }, [holdsOpenCard]);
1469
+ const collapsible = isDone && list.length > 0;
1470
+ // 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
1471
+ // React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
1472
+ // 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
1473
+ const folded = collapsible && !open;
1474
+ // 稳定的组体 id:按钮的 aria-controls 必须指向真实存在的节点,
1475
+ // 折叠后节点仍在 DOM 中,所以展开/折叠两侧都成立。
1476
+ const bodyId = `dsh-wp-group-body-${group}`;
1477
+ // T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
1478
+ // 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
1479
+ const totals = useMemo(() => groupTotals(list), [list]);
1480
+ const groupTitle = t(isDone ? 'groupDone' : 'groupLive');
1481
+ const hintText = t(isDone ? 'groupDoneHint' : 'groupLiveHint');
1482
+ const summaryText = totals.percent === null
1483
+ ? fill(t('groupSummaryNoRate'), { open: totals.open, done: totals.done })
1484
+ : fill(t('groupSummary'), { open: totals.open, done: totals.done, pct: totals.percent });
1485
+ // 读屏用完整句:计数、完成率与三态明细一起念,避免只听到一个孤零零的数字。
1486
+ const ariaText = totals.total > 0
1487
+ ? fill(t('groupAria'), {
1488
+ title: groupTitle,
1489
+ plans: list.length,
1490
+ total: totals.total,
1491
+ done: totals.done,
1492
+ pct: totals.percent,
1493
+ active: totals.active,
1494
+ pending: totals.pending,
1495
+ blocked: totals.blocked,
1496
+ })
1497
+ : fill(t('groupAriaEmpty'), { title: groupTitle, plans: list.length });
1498
+ const headChildren = [
1499
+ h('span', { key: 'dot', className: 'dsh-wp-group-dot', 'aria-hidden': 'true' }),
1500
+ // 组头在折叠态是 <button>,HTML 内容模型不允许 h2 嵌在 button 内,
1501
+ // 所以用 span + role=heading 保留文档大纲语义(T2 报告 C2)。
1502
+ h('span', { key: 'title', className: 'dsh-wp-group-title', role: 'heading', 'aria-level': 2 }, groupTitle),
1503
+ h('span', { key: 'count', className: 'dsh-wp-group-count', title: `${list.length} ${t('groupPlansLabel')}` }, list.length),
1504
+ !isDone && urgent > 0 ? h('span', { key: 'alert', className: 'dsh-wp-group-alert' }, `${t('groupNeeds')} ${urgent}`) : null,
1505
+ h(GroupRing, { key: 'ring', percent: totals.percent }),
1506
+ collapsible ? h('span', { key: 'chevron', className: 'dsh-wp-group-chevron', 'aria-hidden': 'true' }) : null,
1507
+ // 摘要独占一整行(CSS 里 flex-basis:100%):窄面板只截断摘要本身,
1508
+ // 组名/徽章/环不会被挤走;完整句放在 title,悬停可读全。
1509
+ h('span', { key: 'summary', className: 'dsh-wp-group-sub', title: hintText }, summaryText),
1510
+ ];
1511
+ const head = collapsible
1512
+ ? h('button', {
1513
+ type: 'button',
1514
+ className: 'dsh-wp-group-head dsh-wp-group-head--btn',
1515
+ 'aria-expanded': open,
1516
+ 'aria-controls': bodyId,
1517
+ 'aria-label': `${open ? t('groupCollapse') : t('groupExpand')}。${ariaText}`,
1518
+ onClick: () => setOpen(!open),
1519
+ }, ...headChildren)
1520
+ : h('div', { className: 'dsh-wp-group-head' }, ...headChildren);
1521
+ return h('section', {
1522
+ className: `dsh-wp-group dsh-wp-group--${group}${folded ? ' dsh-wp-group--folded' : ''}`,
1523
+ 'data-group': group,
1524
+ 'data-folded': folded ? 'true' : 'false',
1525
+ 'data-total': String(totals.total),
1526
+ 'data-done': String(totals.done),
1527
+ 'data-percent': totals.percent === null ? 'none' : String(totals.percent),
1528
+ }, head, list.length
1529
+ ? h('div', {
1530
+ id: bodyId,
1531
+ className: `dsh-wp-group-body${folded ? ' dsh-wp-group-body--folded' : ''}`,
1532
+ hidden: folded ? true : undefined,
1533
+ 'aria-hidden': folded ? 'true' : undefined,
1534
+ }, list.map((session) => h(SessionCard, {
1535
+ key: session.sessionId,
1536
+ session,
1537
+ t,
1538
+ open: openSessionId === session.sessionId,
1539
+ onToggle: (next) => setOpenSessionId(next ? session.sessionId : null),
1540
+ })))
1541
+ : h('div', { className: 'dsh-wp-group-empty' }, t(isDone ? 'groupDoneEmpty' : 'groupLiveEmpty')));
1542
+ }
1543
+ function Donut({ totals, t }) {
1544
+ const total = Math.max(0, Number(totals?.total) || 0);
1545
+ const done = Math.max(0, Number(totals?.done) || 0);
1546
+ const active = Math.max(0, Number(totals?.inProgress) || 0);
1547
+ const blocked = Math.max(0, Number(totals?.blocked) || 0);
1548
+ const pending = Math.max(0, Number(totals?.pending) || 0);
1549
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
1550
+ const radius = 42;
1551
+ const circumference = 2 * Math.PI * radius;
1552
+ const segments = [
1553
+ { key: 'done', value: done, color: 'var(--dsh-wp-done)' },
1554
+ { key: 'active', value: active, color: 'var(--dsh-wp-active)' },
1555
+ { key: 'blocked', value: blocked, color: 'var(--dsw-alias-state-error-primary)' },
1556
+ { key: 'pending', value: pending, color: 'var(--dsw-alias-state-idle-primary)' },
1557
+ ];
1558
+ let offset = 0;
1559
+ const arcs = [];
1560
+ for (const segment of segments) {
1561
+ if (total <= 0 || segment.value <= 0)
1562
+ continue;
1563
+ const length = (segment.value / total) * circumference;
1564
+ arcs.push(h('circle', {
1565
+ key: segment.key,
1566
+ cx: 50,
1567
+ cy: 50,
1568
+ r: radius,
1569
+ fill: 'none',
1570
+ stroke: segment.color,
1571
+ strokeWidth: 12,
1572
+ strokeDasharray: `${length} ${circumference - length}`,
1573
+ strokeDashoffset: -offset,
1574
+ transform: 'rotate(-90 50 50)',
1575
+ }));
1576
+ offset += length;
1577
+ }
1578
+ const legend = [
1579
+ { key: 'done', label: t('stats.done'), value: done, color: 'var(--dsh-wp-done)' },
1580
+ { key: 'active', label: t('stats.inProgress'), value: active, color: 'var(--dsh-wp-active)' },
1581
+ { key: 'blocked', label: t('stats.blocked'), value: blocked, color: 'var(--dsw-alias-state-error-primary)' },
1582
+ { key: 'pending', label: t('stats.pending'), value: pending, color: 'var(--dsw-alias-state-idle-primary)' },
1583
+ ];
1584
+ return h('section', { className: 'dsh-wp-chart-card dsh-wp-donut-card' }, h('div', { className: 'dsh-wp-section-title' }, t('statusChart')), h('div', { className: 'dsh-wp-donut-wrap' }, h('svg', { className: 'dsh-wp-donut', viewBox: '0 0 100 100', role: 'img', 'aria-label': t('statusChart') }, h('circle', {
1585
+ cx: 50, cy: 50, r: radius, fill: 'none',
1586
+ stroke: 'var(--dsw-alias-bg-layer-2)', strokeWidth: 12,
1587
+ }), arcs), h('div', { className: 'dsh-wp-donut-center' }, h('strong', null, `${percent}%`), h('span', null, t('stats.done')))), h('div', { className: 'dsh-wp-legend' }, legend.map((item) => h('span', { key: item.key, className: 'dsh-wp-legend-item' }, h('i', { className: 'dsh-wp-legend-dot', style: { background: item.color } }), `${item.label} ${item.value}`))));
1588
+ }
1589
+ function TaskBars({ sessions, t, onSelect }) {
1590
+ 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) => {
1591
+ const stats = session.stats || {};
1592
+ const total = Math.max(0, Number(stats.total) || 0);
1593
+ const done = Math.max(0, Number(stats.done) || 0);
1594
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
1595
+ const tone = phaseTone(session.phase || 'pending');
1596
+ return h('button', {
1597
+ key: session.sessionId,
1598
+ type: 'button',
1599
+ className: 'dsh-wp-bar-row dsh-wp-bar-button',
1600
+ 'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
1601
+ onClick: () => { if (typeof onSelect === 'function')
1602
+ onSelect(session.sessionId); },
1603
+ }, 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.${session.phase || 'pending'}`)), h('span', null, `${percent}%`)));
1604
+ }));
1605
+ }
1606
+ function Dashboard({ t }) {
1607
+ const { sessions, error, loading, updatedAt, reload } = usePlans();
1608
+ const [openSessionId, setOpenSessionId] = useState(null);
1609
+ const selectSession = useCallback((sessionId) => {
1610
+ if (!sessionId)
1611
+ return;
1612
+ setOpenSessionId(sessionId);
1613
+ const reveal = () => {
1614
+ const target = document.getElementById(`dsh-wp-session-${sessionId}`);
1615
+ if (!target)
1616
+ return;
1617
+ if (typeof target.scrollIntoView === 'function') {
1618
+ target.scrollIntoView({ block: 'start', behavior: 'smooth' });
1619
+ }
1620
+ const toggle = target.querySelector('.dsh-wp-collapse-toggle');
1621
+ if (toggle && typeof toggle.focus === 'function') {
1622
+ ;
1623
+ toggle.focus({ preventScroll: true });
1624
+ }
1625
+ };
1626
+ if (typeof requestAnimationFrame === 'function') {
1627
+ requestAnimationFrame(() => setTimeout(reveal, 60));
1628
+ }
1629
+ else {
1630
+ setTimeout(reveal, 60);
1631
+ }
1632
+ }, []);
1633
+ const activeSessions = useMemo(() => sessions.filter((session) => session.phase !== 'cancelled'), [sessions]);
1634
+ const totals = useMemo(() => {
1635
+ let total = 0;
1636
+ let done = 0;
1637
+ let blocked = 0;
1638
+ let inProgress = 0;
1639
+ let pending = 0;
1640
+ for (const session of activeSessions) {
1641
+ total += session.stats?.total ?? 0;
1642
+ done += session.stats?.done ?? 0;
1643
+ blocked += session.stats?.blocked ?? 0;
1644
+ inProgress += session.stats?.inProgress ?? 0;
1645
+ pending += session.stats?.pending ?? 0;
1646
+ }
1647
+ return { total, done, blocked, inProgress, pending };
1648
+ }, [activeSessions]);
1649
+ const groups = useMemo(() => {
1650
+ const live = [];
1651
+ const finished = [];
1652
+ const urgentPhases = ['blocked', 'degraded', 'paused'];
1653
+ for (const session of activeSessions) {
1654
+ if (session.phase === 'done')
1655
+ finished.push(session);
1656
+ else
1657
+ live.push(session);
1658
+ }
1659
+ /*
1660
+ * 设计稿 §3.3:blocked 单独最优先(0),paused/degraded 次之(1),
1661
+ * executing(2) → integrating(3) → planning(4) → pending(5) 逐档展开。
1662
+ * 同档用 createdAt 倒序,sessionId 只做稳定兜底——不用 updatedAt:
1663
+ * 轮询每 4 秒刷新它,会让卡片在用户眼前反复跳位(§7.2 禁止布局动画)。
1664
+ */
1665
+ const phaseRank = {
1666
+ blocked: 0,
1667
+ paused: 1,
1668
+ degraded: 1,
1669
+ executing: 2,
1670
+ integrating: 3,
1671
+ planning: 4,
1672
+ pending: 5,
1673
+ };
1674
+ const rank = (session) => phaseRank[session.phase] ?? 5;
1675
+ live.sort((a, b) => (rank(a) - rank(b))
1676
+ || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
1677
+ || String(a.sessionId).localeCompare(String(b.sessionId)));
1678
+ /*
1679
+ * 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
1680
+ * statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
1681
+ * updatedAt,再退回 createdAt。不能直接用 updatedAt:它会被轮询刷新,
1682
+ * 把「最近完成」变成「最近刷新」。
1683
+ */
1684
+ const toFinite = (value) => {
1685
+ if (value === null || value === undefined)
1686
+ return null;
1687
+ const parsed = Number(value);
1688
+ return Number.isFinite(parsed) ? parsed : null;
1689
+ };
1690
+ const finishedAt = (session) => {
1691
+ let latest = null;
1692
+ for (const item of Array.isArray(session.statuses) ? session.statuses : []) {
1693
+ const at = toFinite(item?.finishedAt);
1694
+ if (at !== null && (latest === null || at > latest))
1695
+ latest = at;
1696
+ }
1697
+ if (latest !== null)
1698
+ return latest;
1699
+ return toFinite(session.updatedAt) ?? toFinite(session.createdAt) ?? 0;
1700
+ };
1701
+ finished.sort((a, b) => (finishedAt(b) - finishedAt(a))
1702
+ || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
1703
+ return { live, finished, urgent: live.filter((session) => urgentPhases.includes(session.phase)).length };
1704
+ }, [activeSessions]);
1705
+ const liveBars = useMemo(() => groups.live.slice(0, 6).map((session) => {
1706
+ const doneCount = session.stats?.done ?? 0;
1707
+ const totalCount = session.stats?.total ?? 0;
1708
+ const raw = String(session.title || t('panel'));
1709
+ return {
1710
+ label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
1711
+ value: totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0,
1712
+ };
1713
+ }), [groups, t]);
1714
+ 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
1715
+ ? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
1716
+ : h('div', { className: 'dsh-wp-groups' }, h(SessionGroup, { group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), h(SessionGroup, { group: 'done', list: groups.finished, hasLive: groups.live.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
1717
+ // 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
1718
+ // 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
1719
+ // 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
1720
+ activeSessions.length > 0 ? h('div', { className: 'dsh-wp-overview' }, h(Donut, { totals, t }), h(TaskBars, { sessions: activeSessions, t, onSelect: selectSession })) : null,
1721
+ // 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
1722
+ // (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
1723
+ h(GroupCharts, { liveCount: groups.live.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length - 6), t })));
1724
+ }
1725
+ function TurnCard({ sessionId, turn, t }) {
1726
+ const { sessions } = usePlans(sessionId);
1727
+ const session = sessions[0];
1728
+ if (!session)
1729
+ return null;
1730
+ const createdTurn = typeof session.createdTurn === 'number' ? session.createdTurn : null;
1731
+ const currentTurn = typeof turn?.turn === 'number' ? turn.turn : null;
1732
+ if (createdTurn !== null && currentTurn !== null) {
1733
+ if (currentTurn < createdTurn)
1734
+ return null;
1735
+ const active = ['planning', 'executing', 'blocked'].includes(session.phase);
1736
+ if (currentTurn > createdTurn && !active)
1737
+ return null;
1738
+ }
1739
+ const stats = session.stats || {};
1740
+ const tone = phaseTone(session.phase || 'pending');
1741
+ 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.${session.phase || 'pending'}`))), 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'))));
1742
+ }
1743
+ function contentText(block) {
1744
+ if (!block || !Array.isArray(block.content))
1745
+ return '';
1746
+ return block.content
1747
+ .filter((item) => item && item.type === 'text' && typeof item.text === 'string')
1748
+ .map((item) => item.text)
1749
+ .join('\n')
1750
+ .trim();
1751
+ }
1752
+ function ToolRow({ title, argsLabel, phase, argsRaw, block, t }) {
1753
+ let args = null;
1754
+ if (typeof argsRaw === 'string' && argsRaw.trim() !== '') {
1755
+ try {
1756
+ args = JSON.parse(argsRaw);
1757
+ }
1758
+ catch {
1759
+ args = null;
1760
+ }
1761
+ }
1762
+ const finished = phase === 'result';
1763
+ const text = finished ? contentText(block) : '';
1764
+ const failed = finished && block?.isError === true;
1765
+ const label = failed
1766
+ ? t('toolFailed')
1767
+ : phase === 'preparing'
1768
+ ? t('toolPreparing')
1769
+ : phase === 'start'
1770
+ ? t('toolRunning')
1771
+ : t('toolDone');
1772
+ return h('div', { className: 'dsh-wp-tool' }, h('div', { className: 'dsh-wp-tool-head' }, h('span', null, title), h(Pill, { tone: failed ? 'blocked' : finished ? 'done' : 'active' }, label)), args ? h('div', { className: 'dsh-wp-tool-body' }, `${argsLabel}: ${args.subtaskId ? `${args.subtaskId} → ${args.status || ''}` : args.planId || t('toolArgs')}`)
1773
+ : null, text ? h('div', { className: 'dsh-wp-tool-body' }, text) : null, failed && block?.error?.code
1774
+ ? h('div', { className: 'dsh-wp-error-text' }, String(block.error.code))
1775
+ : null);
1776
+ }
1777
+ function toolArgsRaw(props) {
1778
+ if (props.phase === 'start')
1779
+ return props.block?.argsRaw ?? '';
1780
+ if (props.phase === 'result')
1781
+ return props.block?.call?.argsRaw ?? '';
1782
+ return '';
1783
+ }
1784
+ function createUpdateView(t) {
1785
+ return function UpdateView(props) {
1786
+ return h(ToolRow, {
1787
+ title: t('toolUpdateTitle'),
1788
+ argsLabel: t('toolArgs'),
1789
+ phase: props.phase,
1790
+ argsRaw: toolArgsRaw(props),
1791
+ block: props.block,
1792
+ t,
1793
+ });
1794
+ };
1795
+ }
1796
+ function createDispatchView(t) {
1797
+ return function DispatchView(props) {
1798
+ return h(ToolRow, {
1799
+ title: t('toolDispatchTitle'),
1800
+ argsLabel: t('toolArgs'),
1801
+ phase: props.phase,
1802
+ argsRaw: toolArgsRaw(props),
1803
+ block: props.block,
1804
+ t,
1805
+ });
1806
+ };
1807
+ }
1808
+ function WorkplanIcon() {
1809
+ return h('span', { className: 'dsh-wp-sidebar-icon', 'aria-hidden': 'true' }, h('svg', { viewBox: '0 0 24 24', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }, h('rect', { x: 3, y: 4, width: 18, height: 4, rx: 1.5 }), h('rect', { x: 3, y: 10.5, width: 12, height: 4, rx: 1.5 }), h('rect', { x: 3, y: 17, width: 15, height: 4, rx: 1.5 }), h('circle', { cx: 19, cy: 19, r: 2, fill: 'currentColor', stroke: 'none' })));
1810
+ }
1811
+ class Boundary extends React.Component {
1812
+ constructor(props) {
1813
+ super(props);
1814
+ this.state = { failed: false };
1815
+ }
1816
+ static getDerivedStateFromError() {
1817
+ return { failed: true };
1818
+ }
1819
+ render() {
1820
+ if (this.state.failed) {
1821
+ return h('div', { className: 'dsh-wp' }, h('div', { className: 'dsh-wp-banner dsh-wp-banner--error', role: 'alert' }, this.props.label || 'Task Workplan'));
1822
+ }
1823
+ return this.props.children;
1824
+ }
1825
+ }
1826
+ function guard(component, label) {
1827
+ return function Guarded(props) {
1828
+ return h(Boundary, { label }, h(component, props));
1829
+ };
1830
+ }
1831
+ const GuardedDashboard = guard(Dashboard, 'Task Workplan');
1832
+ const GuardedTurnCard = guard(TurnCard, 'Task Workplan');
1833
+ return {
1834
+ inject: ['slots', 'locale'],
1835
+ // Reusable presentational components: exported so the task layer and the
1836
+ // step layer in this bundle share one disclosure implementation, and so
1837
+ // component tests can mount them without copying markup.
1838
+ components: { Collapse },
1839
+ apply(ctx) {
1840
+ ctx.effect(() => injectStyles());
1841
+ ctx.effect(() => {
1842
+ const zh = ctx.locale.register(NS, 'zh', flattenLocale(LOCALE.zh));
1843
+ const en = ctx.locale.register(NS, 'en', flattenLocale(LOCALE.en));
1844
+ return () => {
1845
+ zh?.();
1846
+ en?.();
1847
+ };
1848
+ });
1849
+ const boundT = ctx.locale.bind(NS);
1850
+ const t = (key, ...args) => {
1851
+ const value = boundT(key, ...args);
1852
+ if (value === key && String(key).includes('.')) {
1853
+ console.warn(`[dsh-workplan] missing locale key: ${String(key)}`);
1854
+ return '—';
1855
+ }
1856
+ return value;
1857
+ };
1858
+ ctx.slots.inject('main', () => ctx.slots.register({
1859
+ name: 'main',
1860
+ key: PANEL_ID,
1861
+ locale: NS,
1862
+ inject: () => ({ t }),
1863
+ }, GuardedDashboard));
1864
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
1865
+ name: 'sidebar.panellist',
1866
+ id: PANEL_ID,
1867
+ order: 6,
1868
+ label: () => t('panel'),
1869
+ locale: NS,
1870
+ }, WorkplanIcon));
1871
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
1872
+ name: 'conversation.chat.turnTail',
1873
+ id: 'workplan-turn',
1874
+ order: 30,
1875
+ locale: NS,
1876
+ inject: (sessionId) => ({ sessionId, t }),
1877
+ }, GuardedTurnCard));
1878
+ const UpdateView = createUpdateView(t);
1879
+ const DispatchView = createDispatchView(t);
1880
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
1881
+ name: 'tool.call.toolview',
1882
+ key: 'dsh_workplan_update',
1883
+ locale: NS,
1884
+ }, UpdateView));
1885
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
1886
+ name: 'tool.call.toolview',
1887
+ key: 'dsh_workplan_dispatch',
1888
+ locale: NS,
1889
+ }, DispatchView));
1890
+ },
1891
+ };
1892
+ },
1893
+ });