dsh-workplan 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +267 -2
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client.js +256 -54
- package/lib/host.js +1 -1
- package/lib/plan.js +112 -8
- package/lib/runtime.js +207 -23
- package/package.json +1 -1
- package/src/client.ts +253 -57
- package/src/host.ts +1 -1
- package/src/plan.ts +110 -8
- package/src/runtime.ts +201 -21
package/client.js
CHANGED
|
@@ -19,21 +19,26 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
"panel": "任务进度",
|
|
20
20
|
"groupLive": "进行中",
|
|
21
21
|
"groupDone": "已完成",
|
|
22
|
+
"groupEnded": "已结束",
|
|
22
23
|
"groupLiveHint": "还没结束,正在或即将推进",
|
|
23
24
|
"groupDoneHint": "已全部完成并收尾",
|
|
25
|
+
"groupEndedHint": "这一轮已经停了,没有东西在跑;说「续跑」可继续推进",
|
|
24
26
|
"groupCollapse": "收起",
|
|
25
27
|
"groupExpand": "展开",
|
|
26
28
|
"groupNeeds": "需处理",
|
|
27
29
|
"groupLiveEmpty": "没有进行中的任务",
|
|
28
30
|
"groupDoneEmpty": "还没有完成的任务",
|
|
31
|
+
"groupEndedEmpty": "没有已结束但未完成的任务",
|
|
29
32
|
"groupSummary": "未完成 {open} / 已完成 {done}(完成率 {pct}%)",
|
|
30
33
|
"groupSummaryNoRate": "未完成 {open} / 已完成 {done}(完成率暂无数据)",
|
|
31
34
|
"groupAria": "{title}组,{plans} 个计划,共 {total} 件具体工作,已完成 {done} 件,完成率 {pct}%。其中正在做 {active} 件、卡住 {blocked} 件、还没开始 {pending} 件。",
|
|
32
35
|
"groupAriaEmpty": "{title}组,{plans} 个计划,暂无具体工作项。",
|
|
33
36
|
"groupPlansLabel": "个计划",
|
|
34
|
-
"chartGroups": "
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
+
"chartGroups": "计划数量与完成情况",
|
|
38
|
+
"chartOpen": "未结束",
|
|
39
|
+
"chartDone": "已完成",
|
|
40
|
+
"chartLive": "未完成计划的完成度(按工作项)",
|
|
41
|
+
"chartMore": "另有 {n} 个未完成计划未画入图表",
|
|
37
42
|
"title": "任务进度:一眼看懂",
|
|
38
43
|
"subtitle": "用大白话说明:现在要做什么、谁在做、做到哪一步、接下来做什么。",
|
|
39
44
|
"helpTitle": "先看这三句",
|
|
@@ -43,6 +48,9 @@ window.__ModuleLoader__.load({
|
|
|
43
48
|
"helpNobody": "还没开始",
|
|
44
49
|
"helpDone": "都做完了",
|
|
45
50
|
"helpNextDone": "没有下一步,这件事已完成",
|
|
51
|
+
"helpWaitActive": "等这一步做完,再继续下一项",
|
|
52
|
+
"helpNoPlan": "还没有安排任何工作",
|
|
53
|
+
"nowRemaining": "还剩 {n} 项",
|
|
46
54
|
"helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
|
|
47
55
|
"handleBlocked": "先处理",
|
|
48
56
|
"resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
|
|
@@ -64,7 +72,8 @@ window.__ModuleLoader__.load({
|
|
|
64
72
|
"blocked": "卡住了",
|
|
65
73
|
"paused": "预算用完,可续跑",
|
|
66
74
|
"degraded": "安排失败",
|
|
67
|
-
"cancelled": "已取消"
|
|
75
|
+
"cancelled": "已取消",
|
|
76
|
+
"ended": "已结束,可续跑"
|
|
68
77
|
},
|
|
69
78
|
"status": {
|
|
70
79
|
"pending": "还没开始",
|
|
@@ -180,6 +189,7 @@ window.__ModuleLoader__.load({
|
|
|
180
189
|
"en": {
|
|
181
190
|
"groupLive": "In progress",
|
|
182
191
|
"groupDone": "Completed",
|
|
192
|
+
"groupEnded": "Ended",
|
|
183
193
|
"groupLiveHint": "Not finished yet — running or about to run",
|
|
184
194
|
"groupDoneHint": "All work finished and wrapped up",
|
|
185
195
|
"groupCollapse": "Collapse",
|
|
@@ -187,14 +197,17 @@ window.__ModuleLoader__.load({
|
|
|
187
197
|
"groupNeeds": "needs attention",
|
|
188
198
|
"groupLiveEmpty": "No work in progress",
|
|
189
199
|
"groupDoneEmpty": "Nothing completed yet",
|
|
200
|
+
"groupEndedEmpty": "No ended-but-unfinished plans",
|
|
190
201
|
"groupSummary": "Open {open} / done {done} ({pct}% complete)",
|
|
191
202
|
"groupSummaryNoRate": "Open {open} / done {done} (completion rate unavailable)",
|
|
192
203
|
"groupAria": "{title} group: {plans} plan(s), {total} work item(s), {done} done, {pct}% complete. Right now {active} in progress, {blocked} blocked, {pending} not started.",
|
|
193
204
|
"groupAriaEmpty": "{title} group: {plans} plan(s), no work items yet.",
|
|
194
205
|
"groupPlansLabel": "plan(s)",
|
|
195
|
-
"chartGroups": "Plan count
|
|
196
|
-
"
|
|
197
|
-
"
|
|
206
|
+
"chartGroups": "Plan count and completion",
|
|
207
|
+
"chartOpen": "Open",
|
|
208
|
+
"chartDone": "Completed",
|
|
209
|
+
"chartLive": "Open plans by work item",
|
|
210
|
+
"chartMore": "{n} more open plans not drawn",
|
|
198
211
|
"panel": "Task progress",
|
|
199
212
|
"title": "Task progress at a glance",
|
|
200
213
|
"subtitle": "Plain language: what this is, who is doing it, how far along it is, and what happens next.",
|
|
@@ -205,6 +218,9 @@ window.__ModuleLoader__.load({
|
|
|
205
218
|
"helpNobody": "Not started yet",
|
|
206
219
|
"helpDone": "Everything is done",
|
|
207
220
|
"helpNextDone": "Nothing next; this task is complete",
|
|
221
|
+
"helpWaitActive": "Wait for this step to finish, then continue",
|
|
222
|
+
"helpNoPlan": "No work has been scheduled yet",
|
|
223
|
+
"nowRemaining": "{n} item(s) left",
|
|
208
224
|
"helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
|
|
209
225
|
"handleBlocked": "Fix first",
|
|
210
226
|
"resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
|
|
@@ -226,7 +242,8 @@ window.__ModuleLoader__.load({
|
|
|
226
242
|
"blocked": "Stuck",
|
|
227
243
|
"paused": "Paused (budget spent)",
|
|
228
244
|
"degraded": "Planning failed",
|
|
229
|
-
"cancelled": "Cancelled"
|
|
245
|
+
"cancelled": "Cancelled",
|
|
246
|
+
"ended": "Ended (resumable)"
|
|
230
247
|
},
|
|
231
248
|
"status": {
|
|
232
249
|
"pending": "Not started",
|
|
@@ -396,6 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
396
413
|
.dsh-wp-group::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; }
|
|
397
414
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fb923c, #f97316); }
|
|
398
415
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #38bdf8, #0284c7); }
|
|
416
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #94a3b8, #64748b); }
|
|
399
417
|
/* T6 — 组头多出一行完成度摘要,底部内边距 10px → 6px,两组仍在同一屏内读完。 */
|
|
400
418
|
.dsh-wp-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 0 4px 6px; }
|
|
401
419
|
.dsh-wp-group-head--btn { width: 100%; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 8px; }
|
|
@@ -422,6 +440,8 @@ window.__ModuleLoader__.load({
|
|
|
422
440
|
.dsh-wp-group-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
|
423
441
|
.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; }
|
|
424
442
|
.dsh-wp-group--done .dsh-wp-group-dot { background: #0ea5e9; box-shadow: 0 0 0 4px rgba(14,165,233,.14); }
|
|
443
|
+
/* [1.6.0] 已结束:灰点、不脉冲——"没有东西在跑"这件事不允许用动画表达。 */
|
|
444
|
+
.dsh-wp-group--ended .dsh-wp-group-dot { background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
425
445
|
.dsh-wp-group-title { font-size: 15px; font-weight: 700; letter-spacing: .2px; color: var(--dsw-alias-label-primary, #0f172a); }
|
|
426
446
|
/* 设计稿 §6.1:橙 #f97316 / 白 ≈2.8:1、蓝 #0ea5e9 / 白 ≈2.77:1,均不足 4.5:1,
|
|
427
447
|
15px 粗体也不算法定「大字号」,所以组头一律用主文本色;品牌色只做「面」——
|
|
@@ -430,6 +450,7 @@ window.__ModuleLoader__.load({
|
|
|
430
450
|
.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; }
|
|
431
451
|
.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); }
|
|
432
452
|
.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); }
|
|
453
|
+
.dsh-wp-group--ended .dsh-wp-group-count { background: rgba(148,163,184,.16); box-shadow: inset 0 0 0 1px rgba(148,163,184,.36); }
|
|
433
454
|
/* WCAG AA:错误色叠 12% 红底后,浅色 #ec1313 只有 3.70:1;与主文本色混合 20%
|
|
434
455
|
让浅色变深、深色变浅,两套主题都 ≥4.5:1(实测 5.16:1 / 5.67:1)。 */
|
|
435
456
|
.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; }
|
|
@@ -742,6 +763,7 @@ window.__ModuleLoader__.load({
|
|
|
742
763
|
.dsh-wp-group::before { content: ''; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 0 3px 3px 0; opacity: .9; }
|
|
743
764
|
.dsh-wp-group--live::before { background: linear-gradient(180deg, #fbbf24, #ea580c); }
|
|
744
765
|
.dsh-wp-group--done::before { background: linear-gradient(180deg, #7dd3fc, #0369a1); }
|
|
766
|
+
.dsh-wp-group--ended::before { background: linear-gradient(180deg, #cbd5e1, #94a3b8); }
|
|
745
767
|
.dsh-wp-group--done { opacity: .97; }
|
|
746
768
|
.dsh-wp-group-head--btn { transition: background-color .16s ease; }
|
|
747
769
|
.dsh-wp-group-head--btn:hover { background: rgba(148,163,184,.10); }
|
|
@@ -974,6 +996,12 @@ window.__ModuleLoader__.load({
|
|
|
974
996
|
function phaseTone(phase) {
|
|
975
997
|
if (phase === 'blocked')
|
|
976
998
|
return 'blocked';
|
|
999
|
+
/*
|
|
1000
|
+
* [1.6.0] `ended` = 这一轮停了,但计划没做完。它既不是成功(done),也不能
|
|
1001
|
+
* 冒充正在干活(active);用中性 idle 色,卡在哪由二级徽标 suspendedPhase 说明。
|
|
1002
|
+
*/
|
|
1003
|
+
if (phase === 'ended')
|
|
1004
|
+
return 'idle';
|
|
977
1005
|
if (phase === 'degraded' || phase === 'cancelled' || phase === 'paused')
|
|
978
1006
|
return 'warn';
|
|
979
1007
|
if (phase === 'integrating' || phase === 'done')
|
|
@@ -982,6 +1010,23 @@ window.__ModuleLoader__.load({
|
|
|
982
1010
|
return 'active';
|
|
983
1011
|
return 'idle';
|
|
984
1012
|
}
|
|
1013
|
+
/*
|
|
1014
|
+
* [1.6.0] 「已结束」徽标要把「停在哪儿」一起说出来。
|
|
1015
|
+
*
|
|
1016
|
+
* 只说「已结束,可续跑」,用户不知道是预算用完、卡住了、还是压根没开始;
|
|
1017
|
+
* 只说原阶段(「正在做」),又会像事故截图那样暗示还在跑。两段式表达:
|
|
1018
|
+
* 终态 + 原阶段,一次说清两个事实。
|
|
1019
|
+
*/
|
|
1020
|
+
function phaseLabel(phase, session, t) {
|
|
1021
|
+
const base = t(`phase.${phase}`);
|
|
1022
|
+
if (phase !== 'ended')
|
|
1023
|
+
return base;
|
|
1024
|
+
const suspended = String(session?.suspendedPhase ?? '');
|
|
1025
|
+
if (!suspended)
|
|
1026
|
+
return base;
|
|
1027
|
+
const detail = t(`phase.${suspended}`);
|
|
1028
|
+
return detail && detail !== base ? `${base} · ${detail}` : base;
|
|
1029
|
+
}
|
|
985
1030
|
function allEntries(session) {
|
|
986
1031
|
const byId = new Map((Array.isArray(session?.statuses) ? session.statuses : []).map((item) => [item.subtaskId, item]));
|
|
987
1032
|
const entries = [];
|
|
@@ -1026,14 +1071,21 @@ window.__ModuleLoader__.load({
|
|
|
1026
1071
|
return text;
|
|
1027
1072
|
}
|
|
1028
1073
|
function workerText(session, t) {
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1074
|
+
/*
|
|
1075
|
+
* [1.5.2] 与 `planNow` 同序:卡住优先。
|
|
1076
|
+
*
|
|
1077
|
+
* 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
|
|
1078
|
+
* 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
|
|
1079
|
+
* 卡住需要人介入,两个位置都应先报它。
|
|
1080
|
+
*/
|
|
1033
1081
|
const blocked = findEntry(session, 'blocked');
|
|
1034
1082
|
if (blocked) {
|
|
1035
1083
|
return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`;
|
|
1036
1084
|
}
|
|
1085
|
+
const active = findEntry(session, 'in_progress');
|
|
1086
|
+
if (active) {
|
|
1087
|
+
return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
|
|
1088
|
+
}
|
|
1037
1089
|
const stats = session.stats || {};
|
|
1038
1090
|
if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
|
|
1039
1091
|
return t('helpDone');
|
|
@@ -1060,13 +1112,26 @@ window.__ModuleLoader__.load({
|
|
|
1060
1112
|
const blocked = findEntry(session, 'blocked');
|
|
1061
1113
|
if (blocked)
|
|
1062
1114
|
return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`;
|
|
1115
|
+
/*
|
|
1116
|
+
* [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
|
|
1117
|
+
*
|
|
1118
|
+
* 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
|
|
1119
|
+
* · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
|
|
1120
|
+
* · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
|
|
1121
|
+
* 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
|
|
1122
|
+
*/
|
|
1123
|
+
const counts = planCounts(session.stats || {});
|
|
1124
|
+
if (counts.total > 0 && counts.done === counts.total)
|
|
1125
|
+
return t('helpDone');
|
|
1126
|
+
if (counts.total === 0)
|
|
1127
|
+
return t('helpNoPlan');
|
|
1063
1128
|
const next = findEntry(session, 'pending') || allEntries(session).find((entry) => !entry.item);
|
|
1064
1129
|
if (next)
|
|
1065
1130
|
return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`;
|
|
1066
|
-
const
|
|
1067
|
-
if (
|
|
1068
|
-
return t('
|
|
1069
|
-
return t('
|
|
1131
|
+
const active = findEntry(session, 'in_progress');
|
|
1132
|
+
if (active)
|
|
1133
|
+
return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
|
|
1134
|
+
return t('helpWaitActive');
|
|
1070
1135
|
}
|
|
1071
1136
|
function progressText(item, t) {
|
|
1072
1137
|
const status = item?.status || 'pending';
|
|
@@ -1254,15 +1319,19 @@ window.__ModuleLoader__.load({
|
|
|
1254
1319
|
const done = items.filter(({ item }) => item?.status === 'done').length;
|
|
1255
1320
|
const blocked = items.filter(({ item }) => item?.status === 'blocked').length;
|
|
1256
1321
|
const inProgress = items.some(({ item }) => item?.status === 'in_progress');
|
|
1257
|
-
|
|
1322
|
+
// [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
|
|
1323
|
+
// 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
|
|
1324
|
+
// 在阶段卡显示「还没开始」(T5 C9)。
|
|
1325
|
+
const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle';
|
|
1258
1326
|
// Same tone/counts as before; only the presentation moved into the
|
|
1259
1327
|
// collapsed summary row (done/total + state pill), so the numbers keep one
|
|
1260
1328
|
// source: this step's own items from `statuses`.
|
|
1261
1329
|
const stateText = blocked > 0
|
|
1262
1330
|
? `${blocked} ${t('stats.blocked')}`
|
|
1263
|
-
: done === items.length
|
|
1331
|
+
: items.length > 0 && done === items.length
|
|
1264
1332
|
? t('helpDone')
|
|
1265
|
-
: inProgress ? t('phase.executing')
|
|
1333
|
+
: inProgress ? t('phase.executing')
|
|
1334
|
+
: done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending');
|
|
1266
1335
|
return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` }, h(Collapse, {
|
|
1267
1336
|
className: 'dsh-wp-stage-collapse',
|
|
1268
1337
|
panelClassName: 'dsh-wp-stage-body',
|
|
@@ -1290,6 +1359,17 @@ window.__ModuleLoader__.load({
|
|
|
1290
1359
|
return 0;
|
|
1291
1360
|
return Math.max(0, Math.round(number));
|
|
1292
1361
|
}
|
|
1362
|
+
/*
|
|
1363
|
+
* [1.5.2] 计划级百分比的唯一算式。
|
|
1364
|
+
*
|
|
1365
|
+
* 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
|
|
1366
|
+
* 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
|
|
1367
|
+
* 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
|
|
1368
|
+
* 现在只有这一个函数,其它地方一律调用它。
|
|
1369
|
+
*/
|
|
1370
|
+
function planPercent(done, total) {
|
|
1371
|
+
return total > 0 ? Math.round((done / total) * 100) : null;
|
|
1372
|
+
}
|
|
1293
1373
|
// 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
|
|
1294
1374
|
// 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
|
|
1295
1375
|
function planCounts(stats) {
|
|
@@ -1299,8 +1379,46 @@ window.__ModuleLoader__.load({
|
|
|
1299
1379
|
const blocked = Math.min(planCount(source.blocked), total - done);
|
|
1300
1380
|
const active = Math.min(planCount(source.inProgress), total - done - blocked);
|
|
1301
1381
|
const pending = Math.max(0, total - done - blocked - active);
|
|
1302
|
-
|
|
1303
|
-
|
|
1382
|
+
return { total, done, active, blocked, pending, percent: planPercent(done, total) };
|
|
1383
|
+
}
|
|
1384
|
+
/*
|
|
1385
|
+
* [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
|
|
1386
|
+
*
|
|
1387
|
+
* 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
|
|
1388
|
+
* 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
|
|
1389
|
+
* 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
|
|
1390
|
+
* 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
|
|
1391
|
+
*
|
|
1392
|
+
* 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
|
|
1393
|
+
* 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
|
|
1394
|
+
* 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
|
|
1395
|
+
*/
|
|
1396
|
+
function planPhaseOf(rawPhase, counts) {
|
|
1397
|
+
const phase = String(rawPhase ?? 'pending');
|
|
1398
|
+
if (counts.total > 0) {
|
|
1399
|
+
if (counts.done === counts.total)
|
|
1400
|
+
return 'done';
|
|
1401
|
+
if (phase === 'done') {
|
|
1402
|
+
if (counts.blocked > 0)
|
|
1403
|
+
return 'blocked';
|
|
1404
|
+
if (counts.active > 0)
|
|
1405
|
+
return 'executing';
|
|
1406
|
+
return 'pending';
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return phase;
|
|
1410
|
+
}
|
|
1411
|
+
/*
|
|
1412
|
+
* [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
|
|
1413
|
+
*
|
|
1414
|
+
* 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
|
|
1415
|
+
* fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
|
|
1416
|
+
*/
|
|
1417
|
+
function planWeightedPercent(stats, counts) {
|
|
1418
|
+
const weighted = clampPct(stats?.progress ?? 0);
|
|
1419
|
+
if (counts.total > 0 && counts.done === counts.total)
|
|
1420
|
+
return 100;
|
|
1421
|
+
return Math.min(99, weighted);
|
|
1304
1422
|
}
|
|
1305
1423
|
function planStatusMap(statuses) {
|
|
1306
1424
|
const byId = new Map();
|
|
@@ -1384,6 +1502,16 @@ window.__ModuleLoader__.load({
|
|
|
1384
1502
|
}
|
|
1385
1503
|
if (counts.total > 0 && counts.done === counts.total)
|
|
1386
1504
|
return { state: 'done', text: t('helpDone') };
|
|
1505
|
+
/*
|
|
1506
|
+
* [1.5.2] 部分完成不再落到「还没开始」。
|
|
1507
|
+
*
|
|
1508
|
+
* `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
|
|
1509
|
+
* pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
|
|
1510
|
+
* 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
|
|
1511
|
+
*/
|
|
1512
|
+
if (counts.done > 0) {
|
|
1513
|
+
return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) };
|
|
1514
|
+
}
|
|
1387
1515
|
return { state: 'pending', text: t('stats.pending') };
|
|
1388
1516
|
}
|
|
1389
1517
|
function PlanRing({ percent }) {
|
|
@@ -1456,7 +1584,9 @@ window.__ModuleLoader__.load({
|
|
|
1456
1584
|
if (rows.length < 1 || rows.length > 12)
|
|
1457
1585
|
return null;
|
|
1458
1586
|
return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) }, rows.map((row) => {
|
|
1459
|
-
|
|
1587
|
+
// [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
|
|
1588
|
+
// 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
|
|
1589
|
+
const reach = row.total > 0 ? (row.done / row.total) * 100 : 0;
|
|
1460
1590
|
const width = row.tone === 'pending' ? 0 : reach;
|
|
1461
1591
|
return h('span', {
|
|
1462
1592
|
key: row.id,
|
|
@@ -1473,11 +1603,20 @@ window.__ModuleLoader__.load({
|
|
|
1473
1603
|
}));
|
|
1474
1604
|
}
|
|
1475
1605
|
function PlanViz({ t, counts, now, rows }) {
|
|
1606
|
+
/*
|
|
1607
|
+
* [1.5.2] data-state 也是「完成语义」的一个读者。
|
|
1608
|
+
*
|
|
1609
|
+
* 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
|
|
1610
|
+
* 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
|
|
1611
|
+
* 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
|
|
1612
|
+
*/
|
|
1476
1613
|
const state = counts.percent === null
|
|
1477
1614
|
? 'idle'
|
|
1478
1615
|
: counts.blocked > 0
|
|
1479
1616
|
? 'blocked'
|
|
1480
|
-
: counts.
|
|
1617
|
+
: counts.total > 0 && counts.done === counts.total
|
|
1618
|
+
? 'done'
|
|
1619
|
+
: counts.done > 0 || counts.active > 0 ? 'active' : 'idle';
|
|
1481
1620
|
return h('div', {
|
|
1482
1621
|
className: 'dsh-wp-planviz',
|
|
1483
1622
|
'data-percent': counts.percent === null ? 'none' : String(counts.percent),
|
|
@@ -1496,7 +1635,7 @@ window.__ModuleLoader__.load({
|
|
|
1496
1635
|
className: 'dsh-wp-planoverview',
|
|
1497
1636
|
'data-plan-total': String(counts.total),
|
|
1498
1637
|
'data-plan-done': String(counts.done),
|
|
1499
|
-
}, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${
|
|
1638
|
+
}, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
|
|
1500
1639
|
? h('div', { className: 'dsh-wp-planrows' }, rows.map((row) => {
|
|
1501
1640
|
const width = row.total > 0 ? (row.done / row.total) * 100 : 0;
|
|
1502
1641
|
return h('div', {
|
|
@@ -1514,13 +1653,18 @@ window.__ModuleLoader__.load({
|
|
|
1514
1653
|
}
|
|
1515
1654
|
function SessionCard({ session, t, open, onToggle }) {
|
|
1516
1655
|
const domId = `dsh-wp-session-${session.sessionId}`;
|
|
1517
|
-
const phase = session.phase || 'pending';
|
|
1518
1656
|
const stats = session.stats || {};
|
|
1519
|
-
const tone = phaseTone(phase);
|
|
1520
|
-
const departments = Array.isArray(session.departments) ? session.departments : [];
|
|
1521
1657
|
// 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
|
|
1522
1658
|
// 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
|
|
1523
1659
|
const counts = planCounts(stats);
|
|
1660
|
+
/*
|
|
1661
|
+
* [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
|
|
1662
|
+
* 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
|
|
1663
|
+
* 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
|
|
1664
|
+
*/
|
|
1665
|
+
const phase = planPhaseOf(session.phase || 'pending', counts);
|
|
1666
|
+
const tone = phaseTone(phase);
|
|
1667
|
+
const departments = Array.isArray(session.departments) ? session.departments : [];
|
|
1524
1668
|
const statuses = Array.isArray(session.statuses) ? session.statuses : [];
|
|
1525
1669
|
const stageRows = planStageRows(session.stages, statuses);
|
|
1526
1670
|
const now = planNow(t, counts, planTaskEntries(session.stages, statuses));
|
|
@@ -1532,7 +1676,7 @@ window.__ModuleLoader__.load({
|
|
|
1532
1676
|
panelClassName: 'dsh-wp-session-body',
|
|
1533
1677
|
title: session.title || t('panel'),
|
|
1534
1678
|
meta: h(PlanViz, { t, counts, now, rows: stageRows }),
|
|
1535
|
-
badge: h(Pill, { tone },
|
|
1679
|
+
badge: h(Pill, { tone }, phaseLabel(phase, session, t)),
|
|
1536
1680
|
}, session.updatedAt
|
|
1537
1681
|
? h('div', { className: 'dsh-wp-meta dsh-wp-session-updated' }, `${t('updatedAt')} ${formatTime(session.updatedAt)}`)
|
|
1538
1682
|
: null, open === true ? h(PlanOverview, { t, stats, counts, rows: stageRows }) : null, session.degraded ? h('div', { className: 'dsh-wp-banner dsh-wp-banner--degraded' }, h('strong', null, t('degraded')), h('div', null, plainError(session.error || '')), h('div', { className: 'dsh-wp-muted' }, t('degradedHint'))) : null, h(Guide, { session, t }), departments.length ? h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('who')), h('div', { className: 'dsh-wp-departments' }, departments.map((dept) => h(Pill, { key: dept.id, tone: dept.blocked > 0 ? 'blocked' : dept.done === dept.total ? 'done' : 'active' }, `${deptName(t, dept.id)} ${dept.done}/${dept.total} ${t('stats.done')}`)))) : null, h('div', { className: 'dsh-wp-section' }, h('div', { className: 'dsh-wp-section-title' }, t('steps')), (session.stages || []).map((stage, index) => h(StageCard, {
|
|
@@ -1617,7 +1761,7 @@ window.__ModuleLoader__.load({
|
|
|
1617
1761
|
charts.push(new lib(canvasA, {
|
|
1618
1762
|
type: 'doughnut',
|
|
1619
1763
|
data: {
|
|
1620
|
-
labels: [t('
|
|
1764
|
+
labels: [t('chartOpen'), t('chartDone')],
|
|
1621
1765
|
datasets: [{ data: [liveCount, doneCount], backgroundColor: [liveFill, doneFill], borderWidth: 0, hoverOffset: 5 }],
|
|
1622
1766
|
},
|
|
1623
1767
|
options: {
|
|
@@ -1678,8 +1822,8 @@ window.__ModuleLoader__.load({
|
|
|
1678
1822
|
// 「无 canvas → 不建图 → 不 ready → 无 canvas」的死循环(T3 实测
|
|
1679
1823
|
// 探针输出 canvasNodes:0 / fallbackBars:1)。改为并存:
|
|
1680
1824
|
// fallback 在 ready 前可见,ready 后由 CSS 隐藏,canvas 反向隐藏。
|
|
1681
|
-
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('
|
|
1682
|
-
? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('
|
|
1825
|
+
h('div', { className: 'dsh-wp-chart-canvas' }, h('canvas', { 'data-chart': 'groups', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${doneCount}` })), chartState !== 'ready'
|
|
1826
|
+
? h('div', { className: 'dsh-wp-fallback' }, h('div', { className: 'dsh-wp-fallback-bar', role: 'img', 'aria-label': `${t('chartOpen')} ${liveCount} / ${t('chartDone')} ${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('chartOpen')} ${liveCount}`), h('span', null, h('i', { className: 'dsh-wp-fallback-key dsh-wp-fallback-key--done' }), `${t('chartDone')} ${doneCount}`)))
|
|
1683
1827
|
: null), (liveBars || []).length
|
|
1684
1828
|
? 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)
|
|
1685
1829
|
: null);
|
|
@@ -1729,7 +1873,7 @@ window.__ModuleLoader__.load({
|
|
|
1729
1873
|
total,
|
|
1730
1874
|
done,
|
|
1731
1875
|
open: total - done,
|
|
1732
|
-
percent:
|
|
1876
|
+
percent: planPercent(done, total),
|
|
1733
1877
|
active: Math.max(0, active),
|
|
1734
1878
|
pending: Math.max(0, pending),
|
|
1735
1879
|
blocked: Math.max(0, blocked),
|
|
@@ -1778,11 +1922,18 @@ window.__ModuleLoader__.load({
|
|
|
1778
1922
|
: null), h('span', { className: 'dsh-wp-group-ring-label' }, value === null ? '—' : `${value}%`));
|
|
1779
1923
|
}
|
|
1780
1924
|
function SessionGroup({ group, list, hasLive, urgent, t, openSessionId, setOpenSessionId }) {
|
|
1925
|
+
/*
|
|
1926
|
+
* [1.6.0] 三个分组共用一套组件:live(真的在跑)、ended(这一轮停了但没做完)、
|
|
1927
|
+
* done(全部完成)。`ended` 是本次事故的修复核心——它必须是一个**独立分组**,
|
|
1928
|
+
* 而不是被塞回「进行中」:会话已经结束的事实不允许用「进行中」这三个字表达。
|
|
1929
|
+
*/
|
|
1781
1930
|
const isDone = group === 'done';
|
|
1931
|
+
const isEnded = group === 'ended';
|
|
1932
|
+
const keySuffix = isDone ? 'Done' : isEnded ? 'Ended' : 'Live';
|
|
1782
1933
|
// 设计稿 §6.4 #1/#6:已完成组在有进行中任务时默认折叠,避免历史任务抢走
|
|
1783
1934
|
// 「进行中」的视觉权重;没有进行中任务时默认展开,空的进度栏仍是空的。
|
|
1784
|
-
//
|
|
1785
|
-
const [open, setOpen] = useState(!(isDone && hasLive));
|
|
1935
|
+
// [1.6.0] 「已结束」组同理:还有东西在跑时收起来,只留组头一行结论。
|
|
1936
|
+
const [open, setOpen] = useState(!((isDone || isEnded) && hasLive));
|
|
1786
1937
|
// §7.3「不打扰」的自动展开:只在这两种情形打开一次,不做反复弹开——
|
|
1787
1938
|
// ① 进行中清零(最后一个任务完成);② 用户正展开着的卡片被移进本组。
|
|
1788
1939
|
// 依赖都是布尔值,轮询刷新不会重复触发。
|
|
@@ -1791,7 +1942,7 @@ window.__ModuleLoader__.load({
|
|
|
1791
1942
|
setOpen(true); }, [hasLive]);
|
|
1792
1943
|
useEffect(() => { if (holdsOpenCard)
|
|
1793
1944
|
setOpen(true); }, [holdsOpenCard]);
|
|
1794
|
-
const collapsible = isDone && list.length > 0;
|
|
1945
|
+
const collapsible = (isDone || isEnded) && list.length > 0;
|
|
1795
1946
|
// 折叠只隐藏、不卸载:`hidden` 必须是布尔 true,不能传空字符串——
|
|
1796
1947
|
// React 对 `hidden` 走 DOM 布尔属性赋值,`el.hidden = ''` 会得到 false,
|
|
1797
1948
|
// 只剩 CSS 类在遮挡;那样「折叠态 DOM 保留 + 无障碍语义」就不成立。
|
|
@@ -1802,8 +1953,8 @@ window.__ModuleLoader__.load({
|
|
|
1802
1953
|
// T6:组头完成度摘要 + 零依赖进度环。数值只来自 groupTotals(list),
|
|
1803
1954
|
// 先用纯文本给出「进行中 N / 已完成 M(完成率 x%)」,不点开、不看颜色也能读出进度。
|
|
1804
1955
|
const totals = useMemo(() => groupTotals(list), [list]);
|
|
1805
|
-
const groupTitle = t(
|
|
1806
|
-
const hintText = t(
|
|
1956
|
+
const groupTitle = t(`group${keySuffix}`);
|
|
1957
|
+
const hintText = t(`group${keySuffix}Hint`);
|
|
1807
1958
|
// 用户 Q3:0/0 时不再显示「未完成 0 / 已完成 0(完成率暂无数据)」——
|
|
1808
1959
|
// 环上已经是「—」,组内空态也已说明没有工作项;再重复一遍 0/0 只是噪声。
|
|
1809
1960
|
// 全部做完(100%)仍保留整句,因为那时「未完成 0」正是用户要找的结论。
|
|
@@ -1868,7 +2019,7 @@ window.__ModuleLoader__.load({
|
|
|
1868
2019
|
open: openSessionId === session.sessionId,
|
|
1869
2020
|
onToggle: (next) => setOpenSessionId(next ? session.sessionId : null),
|
|
1870
2021
|
})))
|
|
1871
|
-
: h('div', { className: 'dsh-wp-group-empty' }, t(
|
|
2022
|
+
: h('div', { className: 'dsh-wp-group-empty' }, t(`group${keySuffix}Empty`)));
|
|
1872
2023
|
}
|
|
1873
2024
|
function Donut({ totals, t }) {
|
|
1874
2025
|
const total = Math.max(0, Number(totals?.total) || 0);
|
|
@@ -1919,10 +2070,11 @@ window.__ModuleLoader__.load({
|
|
|
1919
2070
|
function TaskBars({ sessions, t, onSelect }) {
|
|
1920
2071
|
return h('section', { className: 'dsh-wp-chart-card dsh-wp-task-bars' }, h('div', { className: 'dsh-wp-section-title' }, t('taskChart')), (Array.isArray(sessions) ? sessions : []).map((session) => {
|
|
1921
2072
|
const stats = session.stats || {};
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
const percent =
|
|
1925
|
-
const
|
|
2073
|
+
const counts = planCounts(stats);
|
|
2074
|
+
// 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
|
|
2075
|
+
const percent = counts.percent ?? 0;
|
|
2076
|
+
const phase = planPhaseOf(session.phase || 'pending', counts);
|
|
2077
|
+
const tone = phaseTone(phase);
|
|
1926
2078
|
return h('button', {
|
|
1927
2079
|
key: session.sessionId,
|
|
1928
2080
|
type: 'button',
|
|
@@ -1930,7 +2082,7 @@ window.__ModuleLoader__.load({
|
|
|
1930
2082
|
'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
|
|
1931
2083
|
onClick: () => { if (typeof onSelect === 'function')
|
|
1932
2084
|
onSelect(session.sessionId); },
|
|
1933
|
-
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null,
|
|
2085
|
+
}, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, phaseLabel(phase, session, t)), h('span', null, `${percent}%`)));
|
|
1934
2086
|
}));
|
|
1935
2087
|
}
|
|
1936
2088
|
function Dashboard({ t }) {
|
|
@@ -1978,11 +2130,27 @@ window.__ModuleLoader__.load({
|
|
|
1978
2130
|
}, [activeSessions]);
|
|
1979
2131
|
const groups = useMemo(() => {
|
|
1980
2132
|
const live = [];
|
|
2133
|
+
const ended = [];
|
|
1981
2134
|
const finished = [];
|
|
1982
2135
|
const urgentPhases = ['blocked', 'degraded', 'paused'];
|
|
2136
|
+
/*
|
|
2137
|
+
* [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
|
|
2138
|
+
*
|
|
2139
|
+
* 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
|
|
2140
|
+
* 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
|
|
2141
|
+
* 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
|
|
2142
|
+
* `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
|
|
2143
|
+
*
|
|
2144
|
+
* [1.6.0] 新增 ended 分组:宿主已经 `turn/end`(会话这一轮结束、没有任何
|
|
2145
|
+
* 东西在跑),而计划还没做完的,一律进「已结束」,绝不进「进行中」。
|
|
2146
|
+
* 这正是本次事故的现象:会话结束 4 小时后仍显示「进行中」。
|
|
2147
|
+
*/
|
|
1983
2148
|
for (const session of activeSessions) {
|
|
1984
|
-
|
|
2149
|
+
const phase = planPhaseOf(session.phase, planCounts(session.stats));
|
|
2150
|
+
if (phase === 'done')
|
|
1985
2151
|
finished.push(session);
|
|
2152
|
+
else if (phase === 'ended')
|
|
2153
|
+
ended.push(session);
|
|
1986
2154
|
else
|
|
1987
2155
|
live.push(session);
|
|
1988
2156
|
}
|
|
@@ -2000,11 +2168,19 @@ window.__ModuleLoader__.load({
|
|
|
2000
2168
|
integrating: 3,
|
|
2001
2169
|
planning: 4,
|
|
2002
2170
|
pending: 5,
|
|
2171
|
+
ended: 6,
|
|
2003
2172
|
};
|
|
2004
|
-
const rank = (session) => phaseRank[session.phase] ?? 5;
|
|
2173
|
+
const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
|
|
2005
2174
|
live.sort((a, b) => (rank(a) - rank(b))
|
|
2006
2175
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
|
|
2007
2176
|
|| String(a.sessionId).localeCompare(String(b.sessionId)));
|
|
2177
|
+
/*
|
|
2178
|
+
* [1.6.0] 已结束组按「最近结束在前」:`endedAt` 是宿主 turn/end 的时间戳
|
|
2179
|
+
* (后端下发),比 updatedAt 更准确地表达「什么时候停的」。
|
|
2180
|
+
*/
|
|
2181
|
+
const endedAt = (session) => Number(session.endedAt) || Number(session.updatedAt) || Number(session.createdAt) || 0;
|
|
2182
|
+
ended.sort((a, b) => (endedAt(b) - endedAt(a))
|
|
2183
|
+
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
|
|
2008
2184
|
/*
|
|
2009
2185
|
* 已完成组按「最近完成在前」(§3.3 / C8):优先取子任务
|
|
2010
2186
|
* statuses[].finishedAt 的最大值(忽略 null / 非数字),缺失则退回
|
|
@@ -2030,27 +2206,46 @@ window.__ModuleLoader__.load({
|
|
|
2030
2206
|
};
|
|
2031
2207
|
finished.sort((a, b) => (finishedAt(b) - finishedAt(a))
|
|
2032
2208
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0)));
|
|
2033
|
-
return {
|
|
2209
|
+
return {
|
|
2210
|
+
live,
|
|
2211
|
+
ended,
|
|
2212
|
+
finished,
|
|
2213
|
+
urgent: live.filter((session) => urgentPhases.includes(session.phase)).length,
|
|
2214
|
+
/*
|
|
2215
|
+
* [1.6.0] 「已结束」组也需要「需处理」角标:卡住的计划停了仍然是卡住的,
|
|
2216
|
+
* 判定用后端下发的 suspendedPhase(原派发阶段),而不是被收敛后的 ended。
|
|
2217
|
+
*/
|
|
2218
|
+
urgentEnded: ended.filter((session) => urgentPhases.includes(String(session.suspendedPhase ?? ''))).length,
|
|
2219
|
+
};
|
|
2034
2220
|
}, [activeSessions]);
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
const totalCount = session.stats?.total ?? 0;
|
|
2221
|
+
// [1.6.0] 图表口径 = 未结束计划(进行中 + 已结束未完成),与环图标签一致。
|
|
2222
|
+
const liveBars = useMemo(() => [...groups.live, ...groups.ended].slice(0, 6).map((session) => {
|
|
2038
2223
|
const raw = String(session.title || t('panel'));
|
|
2039
2224
|
return {
|
|
2040
2225
|
label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
|
|
2041
|
-
|
|
2226
|
+
// [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
|
|
2227
|
+
value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
|
|
2042
2228
|
};
|
|
2043
2229
|
}), [groups, t]);
|
|
2044
2230
|
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
|
|
2045
2231
|
? h('div', { className: 'dsh-wp-empty' }, h('div', { style: { fontWeight: 650, marginBottom: '4px' } }, t('empty')), h('div', null, t('emptyHint')))
|
|
2046
|
-
: h('div', { className: 'dsh-wp-groups' },
|
|
2232
|
+
: h('div', { className: 'dsh-wp-groups' },
|
|
2233
|
+
/*
|
|
2234
|
+
* [1.6.0] 三组顺序:真的在跑 → 已结束(停在半路) → 已完成。
|
|
2235
|
+
* 「已结束」夹在中间是有意为之:它是用户最需要立刻知道的那一档
|
|
2236
|
+
* (刚停、还没做完),不能滑到历史组的下面去。
|
|
2237
|
+
*/
|
|
2238
|
+
h(SessionGroup, { key: 'live', group: 'live', list: groups.live, urgent: groups.urgent, t, openSessionId, setOpenSessionId }), groups.ended.length > 0
|
|
2239
|
+
? h(SessionGroup, { key: 'ended', group: 'ended', list: groups.ended, hasLive: groups.live.length > 0, urgent: groups.urgentEnded, t, openSessionId, setOpenSessionId })
|
|
2240
|
+
: null, h(SessionGroup, { key: 'done', group: 'done', list: groups.finished, hasLive: groups.live.length > 0 || groups.ended.length > 0, urgent: 0, t, openSessionId, setOpenSessionId }),
|
|
2047
2241
|
// 用户选择 B:总览块(完成率环 + 每个计划的进度条)同样下移到两组之后,
|
|
2048
2242
|
// 让开屏第一屏直接是「进行中」组的任务卡(此前统计条 → 总览 → 说明行 →
|
|
2049
2243
|
// 任务卡,第一张卡要下滑约一屏)。顶部只保留 5 格数字统计条与一行口径说明。
|
|
2050
2244
|
activeSessions.length > 0 ? h('div', { className: 'dsh-wp-overview' }, h(Donut, { totals, t }), h(TaskBars, { sessions: activeSessions, t, onSelect: selectSession })) : null,
|
|
2051
2245
|
// 图表放两组之后:两张卡各 ≈246px,排在组前会让首屏看不到任何一张任务卡
|
|
2052
2246
|
// (设计稿 §8 第 12 条,T8 复核时复现在 1.4.2 源码里)。
|
|
2053
|
-
|
|
2247
|
+
// [1.6.0] 「未结束」= 进行中 + 已结束但未完成:两者都还没做完,都是用户要盯的对象。
|
|
2248
|
+
h(GroupCharts, { liveCount: groups.live.length + groups.ended.length, doneCount: groups.finished.length, liveBars, hiddenBars: Math.max(0, groups.live.length + groups.ended.length - 6), t })));
|
|
2054
2249
|
}
|
|
2055
2250
|
function TurnCard({ sessionId, turn, t }) {
|
|
2056
2251
|
const { sessions } = usePlans(sessionId);
|
|
@@ -2066,9 +2261,16 @@ window.__ModuleLoader__.load({
|
|
|
2066
2261
|
if (currentTurn > createdTurn && !active)
|
|
2067
2262
|
return null;
|
|
2068
2263
|
}
|
|
2264
|
+
/*
|
|
2265
|
+
* [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
|
|
2266
|
+
*
|
|
2267
|
+
* 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
|
|
2268
|
+
* 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
|
|
2269
|
+
*/
|
|
2069
2270
|
const stats = session.stats || {};
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2271
|
+
const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
|
|
2272
|
+
const tone = phaseTone(phase);
|
|
2273
|
+
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 }, phaseLabel(phase, session, t))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
|
|
2072
2274
|
}
|
|
2073
2275
|
function contentText(block) {
|
|
2074
2276
|
if (!block || !Array.isArray(block.content))
|