dsh-task-ledger 0.0.7 → 0.0.9

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/dist/client.js CHANGED
@@ -44,16 +44,6 @@ window.__ModuleLoader__.load({
44
44
  function fetchBoard() {
45
45
  return fetchJson("/board");
46
46
  }
47
- /** 已结案列表(服务端按窗口 / 标签 / 分页过滤)。 */
48
- function fetchHistory(params) {
49
- const query = new URLSearchParams();
50
- if (params.since !== void 0) query.set("since", params.since);
51
- if (params.until !== void 0) query.set("until", params.until);
52
- if (params.tag !== void 0 && params.tag !== "") query.set("tag", params.tag);
53
- if (params.limit !== void 0) query.set("limit", String(params.limit));
54
- const suffix = query.toString();
55
- return fetchJson(`/history${suffix === "" ? "" : `?${suffix}`}`).then((body) => body.records ?? []);
56
- }
57
47
  /** 单条在办 + 过程文档正文(展开行用)。 */
58
48
  function fetchTaskDetail(workspaceId, taskId) {
59
49
  return fetchJson(`/task?${new URLSearchParams({
@@ -61,83 +51,26 @@ window.__ModuleLoader__.load({
61
51
  id: taskId
62
52
  }).toString()}`);
63
53
  }
64
- /** 筛选项候选(在办全量 + 近 13 个月历史的去重 owner / category / tag)。 */
65
- function fetchFilters() {
66
- return fetchJson("/filters");
67
- }
68
54
  //#endregion
69
- //#region src/client/format.ts
55
+ //#region src/client/store.ts
70
56
  /**
71
- * 客户端时间口径**单源**:补零 / 时刻串 / 时间范围 → `--since` / `--until`。
72
- * 走**本机时区**;解析失败按调用方给的兜底返回(不编造时间)。
57
+ * 看板数据仓(client 侧**唯一**取数入口):浮标角标与整页共用同一份快照,
58
+ * 谁先挂载谁触发首读;刷新按钮走同一个 `refresh()`,**不轮询**(2026-10-17 拍板:
59
+ * 后端纯按需,刷新时机归前端 = 打开面板首读 + 手动刷新)。
73
60
  *
74
- * ⚠️ 与 skill 脚本的边界一致:`--since` 取当天 00:00、`--until` 只给日期时含当天整天
75
- * (脚本 `normalize_time` 的 `date_only_ref="23:59:59"`),故这里只拼 `YYYY-MM-DD`。
76
- */
77
- function pad2(value) {
78
- return String(value).padStart(2, "0");
79
- }
80
- /** ISO → `YYYY-MM-DD HH:mm`(`seconds: true` 时补 `:ss`);解析失败返回 `fallback`(缺省原串)。 */
81
- function formatDateTime(iso, opts) {
82
- const ms = Date.parse(iso);
83
- if (Number.isNaN(ms)) return opts?.fallback ?? iso;
84
- const d = new Date(ms);
85
- const base = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
86
- return opts?.seconds === true ? `${base}:${pad2(d.getSeconds())}` : base;
87
- }
88
- /** 周一 00:00 所在日期(与 host `board.ts` 的 `mondayDateString` 同口径:周一为一周之始)。 */
89
- function mondayOf(now) {
90
- const date = new Date(now);
91
- date.setDate(date.getDate() - (date.getDay() + 6) % 7);
92
- return date;
93
- }
94
- /**
95
- * 时间范围 → `{ since, until }`(`YYYY-MM-DD`,含边界)。`all` 返回空对象 = 不拼参数。
96
- * 近一年 = 今天往前推 365 天(与统计卡「近 13 个月文件」不同口径:那是文件窗口,这是创建时间过滤)。
61
+ * 消费方式:`useSyncExternalStore(boardSubscribe, boardSnapshot)`。
62
+ * ⚠️ `getSnapshot` 必须返回**稳定引用**(每次新建对象会被判定为「一直在变」⇒ 无限重渲染),
63
+ * 故只在真变了时才换 `state` 对象。
97
64
  */
98
- function rangeToDates(range, now = /* @__PURE__ */ new Date()) {
99
- const ymd = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
100
- if (range === "week") return {
101
- since: ymd(mondayOf(now)),
102
- until: ymd(now)
103
- };
104
- if (range === "month") return {
105
- since: ymd(new Date(now.getFullYear(), now.getMonth(), 1)),
106
- until: ymd(now)
107
- };
108
- if (range === "year") {
109
- const start = new Date(now);
110
- start.setDate(start.getDate() - 365);
111
- return {
112
- since: ymd(start),
113
- until: ymd(now)
114
- };
115
- }
116
- return {};
117
- }
118
- /** 任务行是否落在时间范围内(在办侧在浏览器里筛;无创建时间的行按「不带时间过滤」处理)。 */
119
- function withinRange(created, since, until) {
120
- if (since === void 0 && until === void 0) return true;
121
- if (created === null || created === void 0 || created === "") return false;
122
- const day = created.slice(0, 10);
123
- if (since !== void 0 && day < since) return false;
124
- if (until !== void 0 && day > until) return false;
125
- return true;
126
- }
127
65
  let state = {
128
66
  status: "loading",
129
67
  board: null,
130
68
  error: null,
131
- history: [],
132
- historyStatus: "idle",
133
- historyError: null,
134
- filters: null,
135
69
  query: {
136
- bucket: "all",
137
- range: "all",
138
- tag: "",
139
- keyword: "",
140
- limit: 500
70
+ scope: "all",
71
+ status: "all",
72
+ workspace: "",
73
+ keyword: ""
141
74
  },
142
75
  refreshing: false
143
76
  };
@@ -169,7 +102,7 @@ window.__ModuleLoader__.load({
169
102
  listeners.delete(listener);
170
103
  };
171
104
  }
172
- /** 重读:在办快照 + 筛选项 +(已结案档时)历史列表。 */
105
+ /** 重读:在办快照 + 完成类统计与近 8 周趋势(一次 `/board` 全带回)。 */
173
106
  async function refresh() {
174
107
  const token = ++runToken;
175
108
  set({ refreshing: true });
@@ -181,8 +114,6 @@ window.__ModuleLoader__.load({
181
114
  board,
182
115
  error: null
183
116
  });
184
- loadFilters(token);
185
- if (state.query.bucket === "closed") await loadHistory(token);
186
117
  } catch (error) {
187
118
  if (token !== runToken) return;
188
119
  set({
@@ -193,88 +124,59 @@ window.__ModuleLoader__.load({
193
124
  if (token === runToken) set({ refreshing: false });
194
125
  }
195
126
  }
196
- async function loadFilters(token) {
197
- try {
198
- const filters = await fetchFilters();
199
- if (token === runToken) set({ filters });
200
- } catch {}
201
- }
202
- async function loadHistory(token) {
203
- const { range, tag, limit } = state.query;
204
- const dates = rangeToDates(range);
205
- set({
206
- historyStatus: "loading",
207
- historyError: null
208
- });
209
- try {
210
- const records = await fetchHistory({
211
- ...dates,
212
- tag,
213
- limit
214
- });
215
- if (token !== runToken) return;
216
- set({
217
- history: records,
218
- historyStatus: "ready"
219
- });
220
- } catch (error) {
221
- if (token !== runToken) return;
222
- set({
223
- historyStatus: "error",
224
- historyError: error instanceof Error ? error.message : String(error)
225
- });
226
- }
227
- }
228
- /** 改筛选:已结案档要重取历史(服务端过滤);在办档只在浏览器里重筛(数据已全)。 */
127
+ /** 改筛选:全部在浏览器里完成(快照已含全部在办),不发请求。 */
229
128
  function setQuery(patch) {
230
- const query = {
129
+ set({ query: {
231
130
  ...state.query,
232
131
  ...patch
233
- };
234
- set({ query });
235
- if (query.bucket === "closed") loadHistory(++runToken);
236
- }
237
- /** 「加载更多」:加大 limit 重取(skill 脚本无 offset,笨办法;500 条一页无感)。 */
238
- function loadMoreHistory() {
239
- setQuery({ limit: state.query.limit + 500 });
132
+ } });
240
133
  }
241
134
  //#endregion
242
135
  //#region src/client/rows.ts
243
- /**
244
- * 列表行的筛选 / 排序(**唯一**实现,纯函数、不碰网络):页面只渲染,不各写一份过滤。
245
- *
246
- * 单独成文件是为了能被冒烟直测(store.ts 引 api.ts,Node 剥类型解析不了无扩展名相对导入)。
247
- */
248
136
  /** 关键字命中(标题 / 任务号 / 人 / 分类 / 标签;已结案另含「完成了什么」)。 */
249
137
  function hitKeyword(keyword, parts) {
250
138
  const needle = keyword.trim().toLowerCase();
251
139
  if (needle === "") return true;
252
140
  return parts.some((part) => part?.toLowerCase().includes(needle) === true);
253
141
  }
142
+ /** 「我的」判定:owner = 该工作区的当前 git 身份,且状态是 doing / blocked(与 host 统计同口径)。 */
143
+ function isMine(task, board) {
144
+ if (board === null) return false;
145
+ const identity = board.identities[task.workspace.id] ?? null;
146
+ if (identity === null || identity === "") return false;
147
+ return task.owner === identity && (task.status === "doing" || task.status === "blocked");
148
+ }
149
+ /** 状态档命中(停滞由 host 标记,前端不判阈值)。 */
150
+ function statusHit(task, status) {
151
+ if (status === "all") return true;
152
+ if (status === "active") return task.status === "doing" || task.status === "blocked";
153
+ if (status === "open") return task.status === "open";
154
+ if (status === "blocked") return task.status === "blocked";
155
+ return task.stale === true;
156
+ }
157
+ /** 组内排序:别人的 doing/blocked 在前、待认领在后;各自按创建 / 开始时间倒序。 */
158
+ function inGroupOrder(a, b) {
159
+ if (a.kind !== "task" || b.kind !== "task") return 0;
160
+ const rank = (task) => task.status === "open" ? 1 : 0;
161
+ if (rank(a.task) !== rank(b.task)) return rank(a.task) - rank(b.task);
162
+ return String(b.task.created ?? b.task.started ?? "").localeCompare(String(a.task.created ?? a.task.started ?? ""));
163
+ }
254
164
  /**
255
- * 当前筛选下的列表行:
256
- * - 已结案档:history 由服务端按窗口 / 标签 / 分页过滤,浏览器只补关键字;
257
- * - 在办三档:快照已含全部在办 ⇒ 全在浏览器里筛(档位 / 时间 / 标签 / 关键字)。
165
+ * 当前筛选下的两区视图:
166
+ * - 第一区「我的待办」= 我名下 doing/blocked(命中状态档 / 工作区 / 关键字);
167
+ * - 第二区「按工作区」= 其余任务(别人的 doing/blocked + 全部待认领),按工作区分组;
168
+ * - 范围=「我的」时只出第一区。
258
169
  */
259
- function selectRows(current) {
260
- const { board, history, query } = current;
261
- if (query.bucket === "closed") return history.filter((record) => hitKeyword(query.keyword, [
262
- record.summary,
263
- record.workpackage,
264
- record.owner,
265
- record.category,
266
- ...record.tags ?? []
267
- ])).map((record, index) => ({
268
- kind: "closed",
269
- key: `${record.src ?? ""}:${record.time ?? ""}:${index}`,
270
- record
271
- }));
272
- const dates = rangeToDates(query.range);
273
- return (board?.tasks ?? []).filter((task) => {
274
- if (query.bucket === "open" && task.status !== "open") return false;
275
- if (query.bucket === "mine" && !isMine(task, board)) return false;
276
- if (!withinRange(task.created, dates.since, dates.until)) return false;
277
- if (query.tag !== "" && !(task.tags ?? []).includes(query.tag)) return false;
170
+ function selectView(current) {
171
+ const { board, query } = current;
172
+ if (board === null) return {
173
+ mine: [],
174
+ groups: [],
175
+ total: 0
176
+ };
177
+ const tasks = board.tasks.filter((task) => {
178
+ if (!statusHit(task, query.status)) return false;
179
+ if (query.workspace !== "" && task.workspace.id !== query.workspace) return false;
278
180
  return hitKeyword(query.keyword, [
279
181
  task.title,
280
182
  task.id,
@@ -283,30 +185,74 @@ window.__ModuleLoader__.load({
283
185
  task.category,
284
186
  ...task.tags ?? []
285
187
  ]);
286
- }).sort((a, b) => String(b.created ?? b.started ?? "").localeCompare(String(a.created ?? a.started ?? ""))).map((task) => ({
188
+ });
189
+ const mineRows = tasks.filter((task) => isMine(task, board)).sort((a, b) => String(b.created ?? b.started ?? "").localeCompare(String(a.created ?? a.started ?? ""))).map((task) => ({
287
190
  kind: "task",
288
191
  key: `${task.workspace.id}:${task.id}`,
289
192
  task,
290
- mine: isMine(task, board)
193
+ mine: true
291
194
  }));
195
+ const rest = query.scope === "mine" ? [] : tasks.filter((task) => !isMine(task, board));
196
+ const byWorkspace = /* @__PURE__ */ new Map();
197
+ for (const task of rest) {
198
+ const entry = byWorkspace.get(task.workspace.id) ?? {
199
+ title: task.workspace.title,
200
+ rows: []
201
+ };
202
+ entry.rows.push({
203
+ kind: "task",
204
+ key: `${task.workspace.id}:${task.id}`,
205
+ task,
206
+ mine: false
207
+ });
208
+ byWorkspace.set(task.workspace.id, entry);
209
+ }
210
+ const groups = [...byWorkspace.entries()].map(([workspaceId, entry]) => ({
211
+ workspaceId,
212
+ title: entry.title,
213
+ doingCount: entry.rows.filter((row) => row.kind === "task" && row.task.status !== "open").length,
214
+ openCount: entry.rows.filter((row) => row.kind === "task" && row.task.status === "open").length,
215
+ rows: entry.rows.sort(inGroupOrder)
216
+ })).sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN"));
217
+ return {
218
+ mine: mineRows,
219
+ groups,
220
+ total: mineRows.length + groups.reduce((sum, group) => sum + group.rows.length, 0)
221
+ };
292
222
  }
293
- /** 「我的」判定:owner = 该工作区的当前 git 身份,且状态是 doing / blocked(与 host 统计同口径)。 */
294
- function isMine(task, board) {
295
- if (board === null) return false;
296
- const identity = board.identities[task.workspace.id] ?? null;
297
- if (identity === null || identity === "") return false;
298
- return task.owner === identity && (task.status === "doing" || task.status === "blocked");
223
+ //#endregion
224
+ //#region src/client/format.ts
225
+ /**
226
+ * 客户端时间口径**单源**:补零 / 时刻串(本机时区)。
227
+ * 解析失败按调用方给的兜底返回(不编造时间)。
228
+ *
229
+ * 2026-10-17 改版后首页不再做时间范围过滤(时间只作用于统计卡与趋势图,由 host 聚合)
230
+ * ⇒ 原来的 `rangeToDates` / `withinRange` / `RangeKey` 一并删除;统计 tab 要做时间筛选时
231
+ * 按当时口径在此重建,别处不许再写一份。
232
+ */
233
+ function pad2(value) {
234
+ return String(value).padStart(2, "0");
235
+ }
236
+ /** ISO → `YYYY-MM-DD HH:mm`(`seconds: true` 时补 `:ss`);解析失败返回 `fallback`(缺省原串)。 */
237
+ function formatDateTime(iso, opts) {
238
+ const ms = Date.parse(iso);
239
+ if (Number.isNaN(ms)) return opts?.fallback ?? iso;
240
+ const d = new Date(ms);
241
+ const base = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
242
+ return opts?.seconds === true ? `${base}:${pad2(d.getSeconds())}` : base;
299
243
  }
300
244
  //#endregion
301
245
  //#region src/client/tokens.ts
302
246
  /**
303
247
  * 语义 token 层:`--dtl-*`(dtl = dsh-task-ledger)。
304
248
  *
305
- * 规矩(与 docs/design/ui-style.md 两条硬约束一致):
249
+ * 规矩(docs/design/ui-style.md §三,2026-10-17 对齐 dispatch 表后收严):
306
250
  * - **只有本文件定义 `--dtl-*`**;其它文件一律 `var(--dtl-*)` 消费,不许直接写 `var(--dsw-*)`;
307
251
  * - 每个 token 都带兜底值 ⇒ 宿主升级换名时不炸(最坏退到兜底色,不会白屏);
308
252
  * - 明暗差异只写在本文件末尾的 `body[data-ds-dark-theme]` 段:能靠宿主 alias 表达的
309
253
  * 一律不写覆盖(alias 自己随主题变),只有「固定中性面 / 半透明底」这类 alias 表达不了的才进该段。
254
+ * - **离散刻度**:圆角 4/8/12/16/20/28、间距 4/8/12/16、控件高 24/28/32、字号 11/12/13/14/16
255
+ * ——业务文件不许出现 5/7/9px、不许自造高度档(要加档位改本文件)。
310
256
  *
311
257
  * 明暗判据 = `body[data-ds-dark-theme]`(宿主 0.2.0-rc.2 源码核实:启动脚本 toggleAttribute 写入)。
312
258
  * 插件**禁用** `prefers-color-scheme`(它跟系统、不跟用户在宿主里的选择)。
@@ -316,11 +262,12 @@ window.__ModuleLoader__.load({
316
262
  */
317
263
  const UI_TOKENS_CSS = `
318
264
  body{
319
- /* ── 文字四档 ─────────────────────────────────────────────────── */
265
+ /* ── 文字四档 + 反色面 ───────────────────────────────────────── */
320
266
  --dtl-fg:var(--dsw-alias-label-primary,#1f2328);
321
267
  --dtl-fg-2:var(--dsw-alias-label-secondary,#5c6370);
322
268
  --dtl-fg-3:var(--dsw-alias-label-tertiary,#767d87);
323
269
  --dtl-fg-4:var(--dsw-alias-label-caption,#9aa1ab);
270
+ --dtl-fg-dim:var(--dsw-alias-label-dimmed,#b9bfc7);
324
271
  /* 反色面上的字(角标 / 实心钮):暗色下是 bluish-1000 ⇒ 用 -foreground 而非 -inverted */
325
272
  --dtl-fg-inverse:var(--dsw-alias-label-primary-foreground,#fff);
326
273
 
@@ -328,24 +275,36 @@ body{
328
275
  --dtl-surface-1:var(--dsw-alias-bg-layer-1,#fff);
329
276
  --dtl-surface-2:var(--dsw-alias-bg-layer-2,#fff);
330
277
  --dtl-surface-base:var(--dsw-alias-bg-base,#fff);
278
+ /* 「选中亮片」底盘(配 shadow-raised 用) */
279
+ --dtl-surface-raised:var(--dsw-alias-bg-layer-1,#fff);
331
280
  /* 固定中性实面(自绘浮标 / 角标这类必须自己变的东西) */
332
281
  --dtl-solid:var(--dsw-static-neutral-00,#fff);
333
282
  --dtl-on-solid:var(--dsw-alias-label-primary,#1f2328);
283
+ /* 斑马纹 / 次层面(浅色下近白、深色下深一档) */
284
+ --dtl-plate:var(--dsw-static-neutral-50,#fafafa);
285
+ --dtl-plate-hover:var(--dsw-static-neutral-100,#f5f5f5);
286
+ /* 表头底:浅色比斑马纹再深一档(两者不能撞色) */
287
+ --dtl-head-bg:var(--dsw-static-neutral-100,#f5f5f5);
334
288
 
335
- /* ── 描边 ────────────────────────────────────────────────────── */
289
+ /* ── 描边四档(宿主真值 l1 4% / l2 10% / l3 12% / l4 16%)────── */
290
+ --dtl-border-faint:var(--dsw-alias-border-l1,#0000000a);
336
291
  --dtl-border:var(--dsw-alias-border-l2,#0000001a);
337
292
  --dtl-border-strong:var(--dsw-alias-border-l3,#0000001f);
293
+ --dtl-border-heavy:var(--dsw-alias-border-l4,#00000029);
338
294
 
339
295
  /* ── 语义色 ──────────────────────────────────────────────────── */
340
- /* 蓝色强调 / 选中一律走这条(不是 --dsw-alias-brand-primary:它明色近黑、暗色近白) */
296
+ /* 品牌「面」色:明色近黑、暗色近白(实心钮选中态即此),不是蓝 */
297
+ --dtl-accent:var(--dsw-alias-brand-primary,#0f1115);
298
+ /* 蓝色强调 / 选中 / 焦点一律走这条 */
341
299
  --dtl-business:var(--dsw-alias-state-business-primary,#3b5bdb);
342
300
  --dtl-success:var(--dsw-alias-state-success-primary,#22c55e);
343
301
  /* ⚠️ 宿主是 state-warn(不是 state-warning,后者无此定义) */
344
302
  --dtl-warning:var(--dsw-alias-state-warn-primary,#f59e0b);
345
303
  --dtl-danger:var(--dsw-alias-state-error-primary,#ec1313);
346
- /* 语义色实面上的字(角标):红绿黄都是中调 ⇒ 白字两个主题都可读 */
304
+ --dtl-link:var(--dsw-alias-link,#3b5bdb);
305
+ /* 语义色实面上的字(角标 / 实心提示):红绿黄都是中调 ⇒ 白字两个主题都可读 */
347
306
  --dtl-on-signal:#fff;
348
- /* 语义色浅底:用 color-mix 现算 ⇒ 状态色跟随宿主 alias,主题一换自动成立 */
307
+ /* 语义色浅底:color-mix 现算 ⇒ 跟随宿主 alias,主题一换自动成立 */
349
308
  --dtl-business-soft:color-mix(in srgb,var(--dtl-business) 8%,transparent);
350
309
  --dtl-success-soft:color-mix(in srgb,var(--dtl-success) 8%,transparent);
351
310
  --dtl-warning-soft:color-mix(in srgb,var(--dtl-warning) 8%,transparent);
@@ -353,6 +312,9 @@ body{
353
312
 
354
313
  /* ── 交互底 ──────────────────────────────────────────────────── */
355
314
  --dtl-hover:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));
315
+ --dtl-active:var(--dsw-alias-interactive-bg-active,#2631481a);
316
+ /* 卡 / 行 hover 高亮:淡蓝(灰高亮在卡底上几乎看不出来) */
317
+ --dtl-card-hover:rgba(37,99,235,.07);
356
318
  /* 展开行底:带透明度的蓝,透出卡片底色(灰底跟斑马纹分不出来) */
357
319
  --dtl-open-bg:rgba(37,99,235,.10);
358
320
  --dtl-open-bg-soft:rgba(37,99,235,.05);
@@ -360,28 +322,44 @@ body{
360
322
  --dtl-chip-bg:rgba(15,15,15,.07);
361
323
  --dtl-chip-bg-hover:rgba(15,15,15,.14);
362
324
 
363
- /* ── 圆角 / 间距 / 控件高度 / 字号 ────────────────────────────── */
325
+ /* ── 圆角(宿主真值 4/8/12/16/20/28)──────────────────────────── */
326
+ --dtl-radius-xs:var(--dsw-radius-xs,4px);
364
327
  --dtl-radius-sm:var(--dsw-radius-sm,8px);
365
328
  --dtl-radius-md:var(--dsw-radius-md,12px);
366
329
  --dtl-radius-lg:var(--dsw-radius-lg,16px);
330
+ --dtl-radius-xl:var(--dsw-radius-xl,20px);
331
+ --dtl-radius-panel:var(--dsw-radius-panel,28px);
332
+
333
+ /* ── 间距四拍(只许这四档)────────────────────────────────────── */
367
334
  --dtl-space-1:4px;
368
335
  --dtl-space-2:8px;
369
336
  --dtl-space-3:12px;
370
337
  --dtl-space-4:16px;
338
+
339
+ /* ── 控件高度:离散三档(调用点不许自定义高度)────────────────── */
371
340
  --dtl-control-h-sm:24px;
372
341
  --dtl-control-h-md:28px;
373
342
  --dtl-control-h-lg:32px;
343
+
344
+ /* ── 字号 / 行高(直绑宿主字号族 11/12/13/14/16)──────────────── */
374
345
  --dtl-font-xs:var(--dsw-font-xxxs-11-font-size,11px);
375
346
  --dtl-font-sm:var(--dsw-font-xxs-12-font-size,12px);
376
347
  --dtl-font-md:var(--dsw-font-xs-13-font-size,13px);
377
348
  --dtl-font-lg:var(--dsw-font-s-14-font-size,14px);
378
349
  --dtl-font-xl:var(--dsw-font-base-16-font-size,16px);
350
+ --dtl-line-xs:var(--dsw-font-xxxs-11-line-height,14px);
351
+ --dtl-line-sm:var(--dsw-font-xxs-12-line-height,18px);
352
+ --dtl-line-md:var(--dsw-font-xs-13-line-height,20px);
353
+ --dtl-line-lg:var(--dsw-font-s-14-line-height,22px);
354
+ --dtl-line-xl:var(--dsw-font-base-16-line-height,24px);
379
355
  --dtl-font-mono:var(--ds-font-family-code,monospace);
380
356
 
381
357
  /* ── 投影 / 动效 / 层级 ──────────────────────────────────────── */
382
358
  --dtl-shadow-1:var(--dsw-elevation-soft,0 4px 16px 0 #00000008);
359
+ --dtl-shadow-raised:var(--dsw-elevation-soft,0 4px 16px 0 #00000008);
383
360
  --dtl-shadow-2:var(--dsw-shadow-lv3,0 12px 32px 0 #00000014);
384
361
  --dtl-dur:var(--ds-transition-duration,.2s);
362
+ --dtl-dur-fast:var(--ds-transition-duration-fast,.12s);
385
363
  --dtl-ease:var(--ds-ease-in-out,cubic-bezier(.4,0,.2,1));
386
364
  --dtl-z-badge:20;
387
365
  }
@@ -391,9 +369,14 @@ body{
391
369
  body[data-ds-dark-theme]{
392
370
  --dtl-solid:var(--dsw-static-neutral-900,#0f0f0f);
393
371
  --dtl-on-solid:var(--dsw-static-neutral-00,#fff);
372
+ --dtl-plate:var(--dsw-static-neutral-850,#212123);
373
+ --dtl-plate-hover:var(--dsw-static-neutral-800,#292929);
374
+ /* 暗色表头反过来:比卡片面亮、比斑马纹浅一档 */
375
+ --dtl-head-bg:var(--dtl-surface-2);
394
376
  /* 暗色下蓝要更亮、透明度更高才压得住深底 */
395
377
  --dtl-open-bg:rgba(96,165,250,.18);
396
378
  --dtl-open-bg-soft:rgba(96,165,250,.08);
379
+ --dtl-card-hover:rgba(96,165,250,.13);
397
380
  /* 暗色 chip 底板反过来掺白 */
398
381
  --dtl-chip-bg:rgba(255,255,255,.08);
399
382
  --dtl-chip-bg-hover:rgba(255,255,255,.16);
@@ -403,129 +386,234 @@ body[data-ds-dark-theme]{
403
386
  --dtl-warning-soft:color-mix(in srgb,var(--dtl-warning) 5%,transparent);
404
387
  --dtl-danger-soft:color-mix(in srgb,var(--dtl-danger) 5%,transparent);
405
388
  }
389
+ `;
390
+ /**
391
+ * 控件皮肤(L2):**一类控件唯一实现**,抄 dispatch 表 controls-css 的规矩——
392
+ * 只消费 var(--dtl-*),一个颜色 / 尺寸 / 圆角 / 字号字面量都不许出现;
393
+ * 高度只取三档,有边框的变体用 1px 真边框 + 内部 padding 补回 ⇒ 有边 / 无边同高。
394
+ */
395
+ const UI_CONTROLS_CSS = `
396
+ /* ── 分段控件(滑动块)────────────────────────────────────────────
397
+ 结构一套 + 两个受控轴:size(sm|md|lg) × variant(default|inset)。
398
+ 变体只覆盖两个颜色变量(--seg-track / --seg-thumb),结构 / 尺寸 / 交互零重复。 */
399
+ .dtl-seg{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:var(--dtl-radius-md);background:var(--seg-track);}
400
+ .dtl-seg--default{--seg-track:var(--dtl-surface-2);--seg-thumb:var(--dtl-surface-raised);border:1px solid var(--dtl-border);}
401
+ .dtl-seg--inset{--seg-track:var(--dtl-hover);--seg-thumb:var(--dtl-surface-raised);border:0;padding:3px;}
402
+ .dtl-seg__item{appearance:none;display:inline-flex;align-items:center;gap:4px;box-sizing:border-box;
403
+ height:calc(var(--dtl-control-h-sm) - 6px);padding:0 12px;border:0;border-radius:var(--dtl-radius-sm);
404
+ background:transparent;color:var(--dtl-fg-2);font-family:inherit;font-size:var(--dtl-font-sm);line-height:var(--dtl-line-sm);
405
+ font-weight:400;white-space:nowrap;cursor:pointer;
406
+ transition:color var(--dtl-dur-fast) var(--dtl-ease),background-color var(--dtl-dur-fast) var(--dtl-ease),box-shadow var(--dtl-dur-fast) var(--dtl-ease);}
407
+ .dtl-seg--md .dtl-seg__item{height:calc(var(--dtl-control-h-md) - 6px);}
408
+ .dtl-seg--lg .dtl-seg__item{height:calc(var(--dtl-control-h-lg) - 6px);}
409
+ .dtl-seg__item:hover:not([aria-pressed='true']):not(:disabled){color:var(--dtl-fg);}
410
+ .dtl-seg__item[aria-pressed='true']{background:var(--seg-thumb);box-shadow:var(--dtl-shadow-raised);color:var(--dtl-fg);font-weight:600;}
411
+ .dtl-seg__item:focus-visible{outline:2px solid var(--dtl-business);outline-offset:-2px;}
412
+ .dtl-seg__item:disabled{cursor:default;opacity:.4;}
413
+ /* 段内角标(如「待认领 3」):语义色实面 + 反白字,0 不渲染;必须是正圆 */
414
+ .dtl-seg__badge{display:inline-flex;align-items:center;justify-content:center;flex:none;
415
+ width:16px;height:16px;padding:0;overflow:hidden;border-radius:50%;
416
+ background:var(--dtl-danger);color:var(--dtl-on-signal);font-size:10px;line-height:16px;font-weight:500;font-variant-numeric:tabular-nums;}
417
+
418
+ /* ── 按钮 / 图标钮 ───────────────────────────────────────────────
419
+ 与 Segmented 同一条高度纪律;variant 只换颜色,结构 / 尺寸 / 交互只此一份。 */
420
+ .dtl-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:var(--dtl-space-1);
421
+ box-sizing:border-box;border:1px solid transparent;border-radius:var(--dtl-radius-md);
422
+ background:transparent;color:var(--dtl-fg);font-family:inherit;font-weight:500;white-space:nowrap;cursor:pointer;
423
+ transition:background-color var(--dtl-dur-fast) var(--dtl-ease),border-color var(--dtl-dur-fast) var(--dtl-ease),color var(--dtl-dur-fast) var(--dtl-ease),opacity var(--dtl-dur-fast) var(--dtl-ease);}
424
+ .dtl-btn--sm{height:var(--dtl-control-h-sm);padding:0 10px;font-size:var(--dtl-font-sm);line-height:var(--dtl-line-sm);}
425
+ .dtl-btn--md{height:var(--dtl-control-h-md);padding:0 12px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
426
+ .dtl-btn--lg{height:var(--dtl-control-h-lg);padding:0 14px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
427
+ .dtl-btn--primary{background:var(--dtl-accent);color:var(--dtl-fg-inverse);}
428
+ .dtl-btn--primary:hover:not(:disabled){opacity:.9;}
429
+ .dtl-btn--outline{background:var(--dtl-surface-1);border-color:var(--dtl-border-strong);color:var(--dtl-fg);}
430
+ .dtl-btn--outline:hover:not(:disabled){background:var(--dtl-hover);}
431
+ .dtl-btn--ghost{color:var(--dtl-fg-2);}
432
+ .dtl-btn--ghost:hover:not(:disabled){background:var(--dtl-hover);color:var(--dtl-fg);}
433
+ .dtl-btn:focus-visible{outline:2px solid var(--dtl-business);outline-offset:2px;}
434
+ .dtl-btn:disabled{cursor:default;opacity:.5;}
435
+ .dtl-iconbtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;
436
+ border:1px solid transparent;border-radius:var(--dtl-radius-sm);background:transparent;color:var(--dtl-fg-2);
437
+ font-family:inherit;cursor:pointer;flex:none;
438
+ transition:background-color var(--dtl-dur-fast) var(--dtl-ease),border-color var(--dtl-dur-fast) var(--dtl-ease),color var(--dtl-dur-fast) var(--dtl-ease),opacity var(--dtl-dur-fast) var(--dtl-ease);}
439
+ .dtl-iconbtn--sm{width:var(--dtl-control-h-sm);height:var(--dtl-control-h-sm);font-size:var(--dtl-font-sm);}
440
+ .dtl-iconbtn--md{width:var(--dtl-control-h-md);height:var(--dtl-control-h-md);font-size:var(--dtl-font-md);}
441
+ .dtl-iconbtn--lg{width:var(--dtl-control-h-lg);height:var(--dtl-control-h-lg);font-size:var(--dtl-font-md);}
442
+ .dtl-iconbtn--outline{background:var(--dtl-surface-1);border-color:var(--dtl-border);color:var(--dtl-fg-2);}
443
+ .dtl-iconbtn:hover:not(:disabled){background:var(--dtl-hover);color:var(--dtl-fg);}
444
+ .dtl-iconbtn:focus-visible{outline:2px solid var(--dtl-business);outline-offset:1px;}
445
+ .dtl-iconbtn:disabled{cursor:default;opacity:.4;}
446
+
447
+ /* ── 输入 / 搜索框 / 下拉 ──────────────────────────────────────── */
448
+ .dtl-input{box-sizing:border-box;border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-md);
449
+ background:var(--dtl-surface-1);color:var(--dtl-fg);font-family:inherit;outline:none;
450
+ transition:border-color var(--dtl-dur-fast) var(--dtl-ease),box-shadow var(--dtl-dur-fast) var(--dtl-ease);}
451
+ .dtl-input--sm{height:var(--dtl-control-h-sm);padding:0 8px;font-size:var(--dtl-font-sm);line-height:var(--dtl-line-sm);}
452
+ .dtl-input--md{height:var(--dtl-control-h-md);padding:0 10px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
453
+ .dtl-input--lg{height:var(--dtl-control-h-lg);padding:0 12px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
454
+ .dtl-input::placeholder{color:var(--dtl-fg-dim);}
455
+ .dtl-input:focus{border-color:var(--dtl-business);box-shadow:0 0 0 1px var(--dtl-business);}
456
+ .dtl-input:disabled{cursor:default;opacity:.5;}
457
+ /* 原生 select: appearance:none + 自绘箭头 ⇒ 与输入框同宗(宿主没有廉价下拉件) */
458
+ .dtl-select{appearance:none;-webkit-appearance:none;box-sizing:border-box;
459
+ border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-md);background:var(--dtl-surface-1);color:var(--dtl-fg);
460
+ font-family:inherit;outline:none;cursor:pointer;
461
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23767d87' stroke-width='1.5'><path d='M4 6.5 8 10.5 12 6.5'/></svg>");
462
+ background-repeat:no-repeat;background-position:right 8px center;background-size:14px;
463
+ transition:border-color var(--dtl-dur-fast) var(--dtl-ease),box-shadow var(--dtl-dur-fast) var(--dtl-ease);}
464
+ .dtl-select--sm{height:var(--dtl-control-h-sm);padding:0 26px 0 8px;font-size:var(--dtl-font-sm);line-height:var(--dtl-line-sm);}
465
+ .dtl-select--md{height:var(--dtl-control-h-md);padding:0 26px 0 10px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
466
+ .dtl-select--lg{height:var(--dtl-control-h-lg);padding:0 26px 0 12px;font-size:var(--dtl-font-md);line-height:var(--dtl-line-md);}
467
+ .dtl-select:focus{border-color:var(--dtl-business);box-shadow:0 0 0 1px var(--dtl-business);}
468
+ .dtl-select:disabled{cursor:default;opacity:.5;}
469
+
470
+ /* ── 卡 / chip / 空态 / 列表行(业务壳,皮肤仍只消费 token)────── */
471
+ .dtl-card{background:var(--dtl-surface-1);border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-md);
472
+ transition:background-color var(--dtl-dur-fast) var(--dtl-ease);}
473
+ .dtl-card--clickable{cursor:pointer;text-align:left;font:inherit;color:inherit;width:100%;}
474
+ .dtl-card--clickable:hover{background:var(--dtl-card-hover);}
475
+ .dtl-card--clickable[data-active='true']{border-color:var(--dtl-business);}
476
+ .dtl-card--clickable:focus-visible{outline:2px solid var(--dtl-business);outline-offset:1px;}
477
+ .dtl-chip{display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 8px;border-radius:var(--dtl-radius-sm);
478
+ background:var(--dtl-chip-bg);color:var(--dtl-fg-2);font-size:var(--dtl-font-xs);line-height:var(--dtl-line-xs);white-space:nowrap;}
479
+ .dtl-row{display:flex;align-items:center;gap:var(--dtl-space-2);width:100%;box-sizing:border-box;
480
+ padding:var(--dtl-space-2) var(--dtl-space-3);border:0;border-bottom:1px solid var(--dtl-border-faint);
481
+ background:transparent;font:inherit;color:inherit;text-align:left;cursor:pointer;
482
+ transition:background-color var(--dtl-dur-fast) var(--dtl-ease);}
483
+ .dtl-row:hover{background:var(--dtl-card-hover);}
484
+ .dtl-row:focus-visible{outline:2px solid var(--dtl-business);outline-offset:-2px;}
485
+ .dtl-row[data-open='true']{background:var(--dtl-open-bg);}
486
+ .dtl-row-body{padding:var(--dtl-space-2) var(--dtl-space-3) var(--dtl-space-3);border-bottom:1px solid var(--dtl-border-faint);
487
+ background:var(--dtl-open-bg-soft);font-size:var(--dtl-font-md);}
488
+ .dtl-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
489
+
490
+ @media (prefers-reduced-motion: reduce){
491
+ .dtl-seg__item,.dtl-btn,.dtl-iconbtn,.dtl-input,.dtl-select,.dtl-card,.dtl-row{transition:none;}
492
+ }
406
493
  `;
407
494
  //#endregion
408
495
  //#region src/client/ui.ts
409
496
  /**
410
- * UI 基础层:样式单源 + 共用元件。
497
+ * UI 基础层:样式单源 + 共用元件(**一类控件只有一份实现**)。
411
498
  *
412
- * 两条纪律(用户 2026-10-17 要求「该抽象的必须抽象」):
413
- * 1. **样式**:所有颜色 / 圆角 / 字号一律 `var(--dtl-*)`(定义只在 tokens.ts),
414
- * 任何文件不许再写第二份 `var(--dsw-*)`、也不许自造 `--dtl-*`;
415
- * 需要 hover / focus / 动画的(内联样式表达不了)才有资格进这里的 `UI_BASE_CSS`。
416
- * 2. **元件**:卡 / chip / 下拉 / 空态 / 搜索框各只有一份实现,页面与浮标共用。
499
+ * 纪律(用户 2026-10-17「样式与方法一律抽象,不要反复重写」;形态抄本机
500
+ * `dsh-task-dispatch-table` 的 `ui/tokens.ts` + `ui/controls-css.ts` + `ui/*.tsx`):
501
+ * 1. **样式**:颜色 / 圆角 / 字号 / 高度一律 `var(--dtl-*)`(定义只在 tokens.ts),
502
+ * 业务文件不许再写第二份 `var(--dsw-*)`、不许自造 `--dtl-*`、不许自造高度档;
503
+ * 2. **元件**:分段 / 按钮 / 图标钮 / 输入 / 下拉 / 卡 / chip / 空态 / 图表各一份,
504
+ * 页面与浮标共用。官方 primitives 只留「官方做得更好」的(StateDot / MarkdownText / 图标)。
417
505
  */
418
- /** 基础层样式(幂等注入;只装 hover / focus / 动画这些内联样式写不了的部分)。 */
506
+ /** 基础层样式(tokens + 控件皮肤;幂等注入)。 */
419
507
  const UI_BASE_CSS = `
420
- /* 统计卡:可点、hover 抬底、选中走蓝描边 + 浅蓝底 */
421
- .dtl-card{
422
- display:flex;flex-direction:column;gap:var(--dtl-space-1);
423
- padding:var(--dtl-space-3) var(--dtl-space-4);
424
- border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-md);
425
- background:var(--dtl-surface-1);color:var(--dtl-fg);
426
- cursor:pointer;text-align:left;font:inherit;
427
- transition:background var(--dtl-dur) var(--dtl-ease),border-color var(--dtl-dur) var(--dtl-ease);
428
- }
429
- .dtl-card:hover{background:var(--dtl-hover)}
430
- .dtl-card[aria-pressed="true"]{border-color:var(--dtl-business);background:var(--dtl-business-soft)}
431
- .dtl-card:focus-visible{outline:2px solid var(--dtl-business);outline-offset:2px}
508
+ /* 统计卡:可点、hover 淡蓝、选中蓝描边 */
509
+ .dtl-stat{display:flex;flex-direction:column;gap:var(--dtl-space-1);
510
+ padding:var(--dtl-space-3) var(--dtl-space-4);text-align:left;}
511
+ .dtl-stat__value{font-size:var(--dtl-font-xl);font-weight:600;line-height:var(--dtl-line-xl);font-variant-numeric:tabular-nums;}
512
+ .dtl-stat__hint{font-size:var(--dtl-font-xs);line-height:var(--dtl-line-xs);color:var(--dtl-fg-3);}
432
513
 
433
- /* 列表行:头部可点,hover 抬底;展开行走 --dtl-open-bg */
434
- .dtl-row{border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-md);overflow:hidden}
435
- .dtl-row + .dtl-row{margin-top:var(--dtl-space-2)}
436
- .dtl-row-head{
437
- display:flex;align-items:center;gap:var(--dtl-space-2);
438
- width:100%;padding:var(--dtl-space-3);border:0;background:transparent;
514
+ /* 列表行:头部可点,hover 淡蓝;展开行走 --dtl-open-bg */
515
+ .dtl-row-head{display:flex;align-items:center;gap:var(--dtl-space-2);
516
+ width:100%;padding:var(--dtl-space-2) var(--dtl-space-3);border:0;background:transparent;
439
517
  color:inherit;font:inherit;text-align:left;cursor:pointer;
440
- transition:background var(--dtl-dur) var(--dtl-ease);
441
- }
442
- .dtl-row-head:hover{background:var(--dtl-hover)}
443
- .dtl-row-head:focus-visible{outline:2px solid var(--dtl-business);outline-offset:-2px}
444
- .dtl-row[data-open="true"]{background:var(--dtl-open-bg)}
445
- .dtl-row-body{padding:0 var(--dtl-space-3) var(--dtl-space-3);border-top:1px solid var(--dtl-border)}
446
-
447
- /* chip(分类 / 标签):小底板走 --dtl-chip-bg,hover 加深 */
448
- .dtl-chip{
449
- display:inline-flex;align-items:center;gap:var(--dtl-space-1);
450
- padding:0 var(--dtl-space-2);height:20px;border-radius:var(--dtl-radius-sm);
451
- background:var(--dtl-chip-bg);color:var(--dtl-fg-2);
452
- font-size:var(--dtl-font-xs);white-space:nowrap;max-width:20ch;overflow:hidden;text-overflow:ellipsis;
453
- }
454
-
455
- /* 原生下拉 / 输入框:宿主没有廉价的选择器,用原生控件 + token 配色(不用自绘菜单) */
456
- .dtl-select,.dtl-input{
457
- height:var(--dtl-control-h-md);box-sizing:border-box;
458
- padding:0 var(--dtl-space-2);border:1px solid var(--dtl-border);border-radius:var(--dtl-radius-sm);
459
- background:var(--dtl-surface-1);color:var(--dtl-fg);
460
- font-family:inherit;font-size:var(--dtl-font-md);
461
- }
462
- .dtl-input{padding-left:var(--dtl-space-3);min-width:220px}
463
- .dtl-select:focus-visible,.dtl-input:focus-visible{outline:2px solid var(--dtl-business);outline-offset:1px}
518
+ transition:background-color var(--dtl-dur-fast) var(--dtl-ease);}
519
+ .dtl-row-head:hover{background:var(--dtl-card-hover);}
520
+ .dtl-row-head:focus-visible{outline:2px solid var(--dtl-business);outline-offset:-2px;}
521
+ .dtl-row[data-open="true"]{background:var(--dtl-open-bg);}
522
+ .dtl-row-body{padding:var(--dtl-space-2) var(--dtl-space-3) var(--dtl-space-3);
523
+ background:var(--dtl-open-bg-soft);font-size:var(--dtl-font-md);}
464
524
 
465
525
  /* 空态:真空 = 虚线引导卡;筛选空 = 小字 */
466
- .dtl-empty-guide{
467
- padding:var(--dtl-space-4);border:1px dashed var(--dtl-border-strong);border-radius:var(--dtl-radius-md);
468
- background:transparent;
469
- }
526
+ .dtl-empty-guide{padding:var(--dtl-space-4);border:1px dashed var(--dtl-border-strong);border-radius:var(--dtl-radius-md);background:transparent;}
470
527
  .dtl-empty-filtered{padding:var(--dtl-space-4) 0;color:var(--dtl-fg-3);font-size:var(--dtl-font-md)}
471
528
 
472
529
  /* 浮标角标:0→n 跳变时一次脉冲,不自动消失 */
473
- .dtl-badge{
474
- position:absolute;top:-2px;right:-2px;min-width:16px;height:16px;padding:0 4px;
530
+ .dtl-badge{position:absolute;top:-2px;right:-2px;min-width:16px;height:16px;padding:0 4px;
475
531
  border-radius:8px;background:var(--dtl-danger);color:var(--dtl-on-signal);
476
- font-size:10px;line-height:16px;text-align:center;font-weight:600;
477
- }
532
+ font-size:10px;line-height:16px;text-align:center;font-weight:600;}
478
533
  .dtl-badge[data-pulse="true"]{animation:dtl-pulse 900ms var(--dtl-ease) 1}
479
534
  @keyframes dtl-pulse{0%{transform:scale(.6);opacity:.4}60%{transform:scale(1.15)}100%{transform:scale(1)}}
480
535
 
481
536
  /* 过程正文:宽度受限、长行折行 */
482
537
  .dtl-worklog{max-width:72ch;font-size:var(--dtl-font-md);line-height:1.6}
538
+
539
+ /* 趋势分组柱状图:细柱(8px)+ 底部基线 + 均值虚线;「精细」靠细柱与参考线 */
540
+ .dtl-gchart{position:relative;display:flex;align-items:flex-end;gap:10px;height:104px;
541
+ border-bottom:1px solid var(--dtl-border);}
542
+ .dtl-gchart__col{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;
543
+ flex:1 1 0;height:100%;min-width:0;}
544
+ .dtl-gchart__pair{display:flex;align-items:flex-end;gap:2px;height:100%;justify-content:center;}
545
+ .dtl-gchart__bar{width:8px;min-height:0;border-radius:var(--dtl-radius-xs) var(--dtl-radius-xs) 0 0;}
546
+ .dtl-gchart__bar--done{background:var(--dtl-business);}
547
+ .dtl-gchart__bar--created{background:var(--dtl-chip-bg);}
548
+ .dtl-gchart__col:hover .dtl-gchart__bar--done{background:var(--dtl-accent);}
549
+ .dtl-gchart__label{font-size:10px;line-height:14px;color:var(--dtl-fg-3);white-space:nowrap;}
550
+ .dtl-gchart__avg{position:absolute;left:0;right:0;border-top:1px dashed var(--dtl-border-strong);pointer-events:none;}
551
+ .dtl-gchart__avgtag{position:absolute;right:0;font-size:10px;line-height:12px;color:var(--dtl-fg-3);
552
+ background:var(--dtl-surface-1);padding:0 2px;transform:translateY(-100%);}
553
+ .dtl-gchart-legend{display:flex;align-items:center;gap:var(--dtl-space-3);font-size:var(--dtl-font-xs);color:var(--dtl-fg-2);}
554
+ .dtl-gchart-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px;}
555
+
556
+ /* 过程正文:宽度受限、长行折行 */
557
+ .dtl-worklog{max-width:72ch;font-size:var(--dtl-font-md);line-height:1.6}
558
+
559
+ /* 趋势柱状图:div 柱 + token 色,无新依赖 */
560
+ .dtl-chart{display:flex;align-items:flex-end;gap:var(--dtl-space-2);height:120px;}
561
+ .dtl-chart__col{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:var(--dtl-space-1);flex:1 1 0;height:100%;min-width:0;}
562
+ .dtl-chart__bar{width:100%;max-width:28px;min-height:2px;border-radius:var(--dtl-radius-xs) var(--dtl-radius-xs) 0 0;
563
+ background:var(--dtl-business);transition:background-color var(--dtl-dur-fast) var(--dtl-ease);}
564
+ .dtl-chart__col:hover .dtl-chart__bar{background:var(--dtl-accent);}
565
+ .dtl-chart__val{font-size:var(--dtl-font-xs);line-height:var(--dtl-line-xs);color:var(--dtl-fg-2);font-variant-numeric:tabular-nums;}
566
+ .dtl-chart__label{font-size:10px;line-height:var(--dtl-line-xs);color:var(--dtl-fg-3);white-space:nowrap;}
483
567
  `;
484
568
  const STYLE_ID = "dtl-ui-base";
485
- /** 幂等注入 token 层 + 基础层(面板与浮标谁先挂载都只注入一次)。 */
569
+ /** 幂等注入 token 层 + 控件皮肤 + 基础层(面板与浮标谁先挂载都只注入一次)。 */
486
570
  function ensureUiBase() {
487
571
  if (typeof document === "undefined") return;
488
572
  if (document.getElementById(STYLE_ID) !== null) return;
489
573
  const style = document.createElement("style");
490
574
  style.id = STYLE_ID;
491
- style.textContent = `${UI_TOKENS_CSS}\n${UI_BASE_CSS}`;
575
+ style.textContent = `${UI_TOKENS_CSS}\n${UI_CONTROLS_CSS}\n${UI_BASE_CSS}`;
492
576
  document.head.appendChild(style);
493
577
  }
494
- /** 统计卡:点一下联动下方列表筛选(aria-pressed = 选中态)。 */
495
- function StatCard(props) {
578
+ /** 分段控件(单选):同一份结构 + size 轴;外观差异只由 variant 的两个颜色变量表达。 */
579
+ function Segmented(props) {
580
+ ensureUiBase();
581
+ const size = props.size ?? "md";
582
+ const variant = props.variant ?? "default";
583
+ return (0, react.createElement)("div", {
584
+ role: "group",
585
+ "aria-label": props.label,
586
+ id: props.id,
587
+ className: `dtl-seg dtl-seg--${variant} dtl-seg--${size}`,
588
+ style: props.style
589
+ }, props.items.map((item) => (0, react.createElement)("button", {
590
+ key: item.value,
591
+ type: "button",
592
+ className: "dtl-seg__item",
593
+ "aria-pressed": item.value === props.value,
594
+ onClick: () => {
595
+ props.onChange(item.value);
596
+ }
597
+ }, item.label, item.badge !== void 0 && item.badge > 0 ? (0, react.createElement)("span", { className: "dtl-seg__badge" }, String(item.badge)) : null)));
598
+ }
599
+ function Button(props) {
600
+ ensureUiBase();
496
601
  return (0, react.createElement)("button", {
497
602
  type: "button",
498
- className: "dtl-card",
499
- "aria-pressed": props.active,
603
+ className: `dtl-btn dtl-btn--${props.variant ?? "outline"} dtl-btn--${props.size ?? "md"}`,
604
+ disabled: props.disabled === true,
500
605
  title: props.title,
501
606
  onClick: props.onClick
502
- }, (0, react.createElement)("span", { style: {
503
- fontSize: "var(--dtl-font-sm)",
504
- color: "var(--dtl-fg-2)"
505
- } }, props.label), (0, react.createElement)("span", { style: {
506
- fontSize: "24px",
507
- fontWeight: 600,
508
- lineHeight: 1.2
509
- } }, props.value));
510
- }
511
- /** chip(分类 / 标签 / 工作区名)。 */
512
- function Chip(props) {
513
- const style = {};
514
- if (props.tone === "signal" && props.color !== void 0) {
515
- style.background = props.color;
516
- style.color = "var(--dtl-on-signal)";
517
- }
518
- return (0, react.createElement)("span", {
519
- className: "dtl-chip",
520
- style,
521
- title: props.text
522
- }, props.text);
607
+ }, props.icon, props.children);
523
608
  }
524
- /** 原生下拉(时间 / 标签筛选共用一份)。 */
609
+ /** 原生下拉(工作区 / 状态共用一份;宿主没有廉价下拉件 ⇒ 原生 + token 皮肤)。 */
525
610
  function Select(props) {
611
+ ensureUiBase();
612
+ const size = props.size ?? "md";
526
613
  return (0, react.createElement)("select", {
527
- className: "dtl-select",
614
+ className: `dtl-select dtl-select--${size}`,
528
615
  "aria-label": props.ariaLabel,
616
+ style: props.width === void 0 ? void 0 : { width: `${props.width}px` },
529
617
  value: props.value,
530
618
  onChange: (event) => {
531
619
  props.onChange(event.target.value);
@@ -535,10 +623,12 @@ body[data-ds-dark-theme]{
535
623
  value: option.value
536
624
  }, option.label)));
537
625
  }
538
- /** 搜索框(关键字过滤)。 */
626
+ /** 搜索框(关键字过滤;占满剩余宽度由调用方的 flex 决定)。 */
539
627
  function SearchInput(props) {
628
+ ensureUiBase();
629
+ const size = props.size ?? "md";
540
630
  return (0, react.createElement)("input", {
541
- className: "dtl-input",
631
+ className: `dtl-input dtl-input--${size}`,
542
632
  type: "search",
543
633
  "aria-label": props.ariaLabel,
544
634
  placeholder: props.placeholder,
@@ -549,8 +639,40 @@ body[data-ds-dark-theme]{
549
639
  }
550
640
  });
551
641
  }
642
+ /** 统计卡:主数字 + 灰色口径小字;点一下联动下方列表筛选。
643
+ * `value` 为 0 时显示「—」(用户 2026-10-17 定稿:0 会被读成「没数据 / 出错了」);
644
+ * `hint` 可以是富文本(如把「停滞」两字标红);
645
+ * `interactive: false` = 纯展示卡(统计台:首页没有已结案可筛,不装可点)。 */
646
+ function StatCard(props) {
647
+ ensureUiBase();
648
+ const interactive = props.interactive !== false && props.onClick !== void 0;
649
+ const shown = props.value === 0 || props.value === "0" ? "—" : String(props.value);
650
+ return (0, react.createElement)(interactive ? "button" : "div", {
651
+ type: interactive ? "button" : void 0,
652
+ className: `dtl-card dtl-stat${interactive ? " dtl-card--clickable" : ""}`,
653
+ "data-active": props.active === true,
654
+ title: props.title,
655
+ onClick: interactive ? props.onClick : void 0
656
+ }, (0, react.createElement)("span", { style: {
657
+ fontSize: "var(--dtl-font-sm)",
658
+ color: "var(--dtl-fg-2)"
659
+ } }, props.label), (0, react.createElement)("span", { className: "dtl-stat__value" }, shown), props.hint === void 0 || props.hint === null || props.hint === "" ? null : (0, react.createElement)("span", { className: "dtl-stat__hint" }, props.hint));
660
+ }
661
+ /** chip(分类 / 标签 / 工作区名)。 */
662
+ function Chip(props) {
663
+ ensureUiBase();
664
+ return (0, react.createElement)("span", {
665
+ className: "dtl-chip",
666
+ style: props.color === void 0 ? void 0 : {
667
+ background: props.color,
668
+ color: "var(--dtl-on-signal)"
669
+ },
670
+ title: props.text
671
+ }, props.text);
672
+ }
552
673
  /** 空态:`guide` = 真空引导卡(虚线),`filtered` = 筛选空小字。 */
553
674
  function EmptyState(props) {
675
+ ensureUiBase();
554
676
  if (props.tone === "filtered") return (0, react.createElement)("div", { className: "dtl-empty-filtered" }, props.title);
555
677
  return (0, react.createElement)("div", { className: "dtl-empty-guide" }, (0, react.createElement)("div", { style: {
556
678
  fontSize: "var(--dtl-font-lg)",
@@ -561,6 +683,42 @@ body[data-ds-dark-theme]{
561
683
  color: "var(--dtl-fg-2)"
562
684
  } }, props.hint));
563
685
  }
686
+ /** 分组柱状图(div 柱,无新依赖):完成 / 新增各一细柱,底部基线 + 完成数平均值虚线。
687
+ * 「精细」= 细柱 + 基线 + 均值线,不是粗柱堆高度(用户 2026-10-17)。 */
688
+ function GroupedBarChart(props) {
689
+ ensureUiBase();
690
+ const plotHeight = 104;
691
+ const max = props.weeks.reduce((peak, week) => Math.max(peak, week.done, week.created), 0);
692
+ const doneTotal = props.weeks.reduce((sum, week) => sum + week.done, 0);
693
+ const avg = props.weeks.length === 0 ? 0 : doneTotal / props.weeks.length;
694
+ const barHeight = (value) => max === 0 ? 0 : Math.round(value / max * plotHeight);
695
+ return (0, react.createElement)("div", { style: {
696
+ position: "relative",
697
+ paddingBottom: "18px"
698
+ } }, (0, react.createElement)("div", {
699
+ className: "dtl-gchart",
700
+ role: "img",
701
+ "aria-label": props.ariaLabel
702
+ }, avg > 0 ? (0, react.createElement)("div", {
703
+ className: "dtl-gchart__avg",
704
+ style: { bottom: `${barHeight(avg)}px` }
705
+ }, (0, react.createElement)("span", { className: "dtl-gchart__avgtag" }, props.avgLabel)) : null, props.weeks.map((week) => (0, react.createElement)("div", {
706
+ key: week.label,
707
+ className: "dtl-gchart__col",
708
+ title: week.title
709
+ }, (0, react.createElement)("div", { className: "dtl-gchart__pair" }, (0, react.createElement)("span", {
710
+ className: "dtl-gchart__bar dtl-gchart__bar--done",
711
+ style: { height: `${barHeight(week.done)}px` }
712
+ }), (0, react.createElement)("span", {
713
+ className: "dtl-gchart__bar dtl-gchart__bar--created",
714
+ style: { height: `${barHeight(week.created)}px` }
715
+ })), (0, react.createElement)("span", { className: "dtl-gchart__label" }, week.label)))));
716
+ }
717
+ /** 图例(完成 / 新增 两个色块)。 */
718
+ function ChartLegend(props) {
719
+ ensureUiBase();
720
+ return (0, react.createElement)("div", { className: "dtl-gchart-legend" }, (0, react.createElement)("span", null, (0, react.createElement)("i", { style: { background: "var(--dtl-business)" } }), props.doneLabel), (0, react.createElement)("span", null, (0, react.createElement)("i", { style: { background: "var(--dtl-chip-bg)" } }), props.createdLabel));
721
+ }
564
722
  //#endregion
565
723
  //#region src/client/task-row.ts
566
724
  /**
@@ -570,6 +728,9 @@ body[data-ds-dark-theme]{
570
728
  *
571
729
  * 行展开 = 过程文档路径 + 过程正文(在办走 `GET /task` 取正文;已结案只有路径——
572
730
  * host 没有按历史行取正文的路由,不编造内容)。
731
+ *
732
+ * `showWorkspace`:第一区「我的待办」是跨工作区平铺 ⇒ 工作区升为一列(定宽,不再是
733
+ * 和标签混在一起的小 chip);第二区分组行由分组标题说明归属,行内不再重复。
573
734
  */
574
735
  /** 行的状态点语义(在办三态 + 已结案)。 */
575
736
  function dotOf(row) {
@@ -589,6 +750,36 @@ body[data-ds-dark-theme]{
589
750
  if (row.kind === "task") return row.task.owner ?? row.task.creator ?? null;
590
751
  return row.record.owner ?? null;
591
752
  }
753
+ /** 工作区徽标(第一区专用列):色块首字母 + 标题,定宽、超出省略(不猜官方图标名)。 */
754
+ function WorkspaceTag(props) {
755
+ if (props.workspace === void 0) return null;
756
+ const initial = props.workspace.title.trim().slice(0, 1);
757
+ return (0, react.createElement)("span", {
758
+ className: "dtl-ellipsis",
759
+ style: {
760
+ display: "inline-flex",
761
+ alignItems: "center",
762
+ gap: "var(--dtl-space-1)",
763
+ flex: "0 0 140px",
764
+ minWidth: 0,
765
+ color: "var(--dtl-fg-2)",
766
+ fontSize: "var(--dtl-font-xs)"
767
+ },
768
+ title: props.workspace.title
769
+ }, (0, react.createElement)("span", { style: {
770
+ display: "inline-flex",
771
+ alignItems: "center",
772
+ justifyContent: "center",
773
+ flex: "none",
774
+ width: "16px",
775
+ height: "16px",
776
+ borderRadius: "var(--dtl-radius-xs)",
777
+ background: "var(--dtl-business-soft)",
778
+ color: "var(--dtl-business)",
779
+ fontSize: "10px",
780
+ fontWeight: 600
781
+ } }, initial === "" ? "·" : initial), props.workspace.title);
782
+ }
592
783
  function TaskRowView(props) {
593
784
  const { t, row } = props;
594
785
  const [open, setOpen] = (0, react.useState)(false);
@@ -627,13 +818,11 @@ body[data-ds-dark-theme]{
627
818
  onClick: () => {
628
819
  setOpen(!open);
629
820
  }
630
- }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.StateDot, { state: dotOf(row) }), (0, react.createElement)("span", {
821
+ }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.StateDot, { state: dotOf(row) }), props.showWorkspace === true ? (0, react.createElement)(WorkspaceTag, { workspace }) : null, (0, react.createElement)("span", {
822
+ className: "dtl-ellipsis",
631
823
  style: {
632
824
  flex: "1 1 auto",
633
825
  minWidth: 0,
634
- overflow: "hidden",
635
- textOverflow: "ellipsis",
636
- whiteSpace: "nowrap",
637
826
  fontSize: "var(--dtl-font-md)"
638
827
  },
639
828
  title: titleOf(row)
@@ -644,10 +833,11 @@ body[data-ds-dark-theme]{
644
833
  display: "flex",
645
834
  alignItems: "center",
646
835
  gap: "var(--dtl-space-2)",
836
+ flex: "none",
647
837
  color: "var(--dtl-fg-3)",
648
838
  fontSize: "var(--dtl-font-xs)",
649
839
  whiteSpace: "nowrap"
650
- } }, workspace === void 0 ? null : (0, react.createElement)("span", null, workspace.title), (0, react.createElement)("span", { title: stamp.value ?? void 0 }, `${stamp.label} ${when}`), (0, react.createElement)("span", null, who === null || who === "" ? t("ownerUnclaimed") : `@${who}`)), (0, react.createElement)(open ? _deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular : _deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 16 })), open ? (0, react.createElement)("div", { className: "dtl-row-body" }, (0, react.createElement)("div", { style: {
840
+ } }, (0, react.createElement)("span", { title: stamp.value ?? void 0 }, `${stamp.label} ${when}`), (0, react.createElement)("span", null, who === null || who === "" ? t("ownerUnclaimed") : `@${who}`)), (0, react.createElement)(open ? _deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular : _deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 16 })), open ? (0, react.createElement)("div", { className: "dtl-row-body" }, (0, react.createElement)("div", { style: {
651
841
  margin: "var(--dtl-space-2) 0",
652
842
  fontSize: "var(--dtl-font-sm)",
653
843
  color: "var(--dtl-fg-2)"
@@ -698,11 +888,12 @@ body[data-ds-dark-theme]{
698
888
  //#endregion
699
889
  //#region src/client/board-home.ts
700
890
  /**
701
- * 看板首页(`main` 槽整页内容):4 统计卡(可点联动筛选)+ 筛选行 + 统一任务列表。
702
- * 定稿见 docs/design/features/board-home.md §二;按钮矩阵归「任务按钮与会话交接」任务(写入路径 A/B 未拍板),
703
- * 本页**不画**任务动作按钮——不先斩后奏。
891
+ * 看板首页(`main` 槽整页内容):6 统计卡 + 近 8 周趋势图 + 两区任务列表。
892
+ * 定稿见 docs/design/features/board-home.md §二(2026-10-17 专家组改版);
893
+ * 按钮矩阵归「任务按钮与会话交接」任务(写入路径 A/B 未拍板),本页**不画**任务动作按钮。
704
894
  *
705
- * 刷新纪律(2026-10-17 拍板):挂载首读 + 手动刷新按钮,**不轮询**;空态三分(未就绪不渲染 / 真空引导卡 / 筛选空小字)。
895
+ * 刷新纪律(2026-10-17 拍板):挂载首读 + 手动刷新按钮,**不轮询**;空态三分
896
+ * (未就绪不渲染 / 真空引导卡 / 筛选空小字)。
706
897
  */
707
898
  /** 整页容器:占满中栏、自己滚动。 */
708
899
  const pageStyle = {
@@ -725,11 +916,34 @@ body[data-ds-dark-theme]{
725
916
  fontSize: "var(--dtl-font-xl)",
726
917
  fontWeight: 600
727
918
  };
728
- const statsGridStyle = {
919
+ /** 操作台:全宽 4 列。列数**写死 4**,不用 auto-fit——那会排出 3+1 的非对称
920
+ * (用户 2026-10-17:必须上下对称;窄屏由 statsRowStyle 的堆叠兜底,仍是 2+2)。 */
921
+ const cardsGridStyle = {
729
922
  display: "grid",
730
- gridTemplateColumns: "repeat(auto-fit,minmax(160px,1fr))",
731
- gap: "var(--dtl-space-3)",
732
- marginBottom: "var(--dtl-space-4)"
923
+ gridTemplateColumns: "repeat(4,minmax(0,1fr))",
924
+ gap: "var(--dtl-space-3)"
925
+ };
926
+ /** 第二行:统计台(2×2)+ 趋势图(右 1/3)。 */
927
+ const statsRowStyle = {
928
+ display: "grid",
929
+ gridTemplateColumns: "minmax(0,2fr) minmax(0,1fr)",
930
+ gap: "var(--dtl-space-3)"
931
+ };
932
+ const statsCardsStyle = {
933
+ display: "grid",
934
+ gridTemplateColumns: "repeat(2,minmax(0,1fr))",
935
+ gap: "var(--dtl-space-3)"
936
+ };
937
+ const regionCaptionStyle = {
938
+ fontSize: "var(--dtl-font-xs)",
939
+ color: "var(--dtl-fg-3)",
940
+ margin: "var(--dtl-space-4) 0 var(--dtl-space-2)"
941
+ };
942
+ const chartCardStyle = {
943
+ display: "flex",
944
+ flexDirection: "column",
945
+ gap: "var(--dtl-space-2)",
946
+ padding: "var(--dtl-space-3) var(--dtl-space-4)"
733
947
  };
734
948
  const filterRowStyle = {
735
949
  display: "flex",
@@ -738,10 +952,43 @@ body[data-ds-dark-theme]{
738
952
  flexWrap: "wrap",
739
953
  marginBottom: "var(--dtl-space-3)"
740
954
  };
955
+ const searchWrapStyle = {
956
+ flex: "1 1 220px",
957
+ minWidth: 0
958
+ };
741
959
  const hintStyle = {
742
960
  fontSize: "var(--dtl-font-sm)",
743
961
  color: "var(--dtl-fg-2)"
744
962
  };
963
+ const regionHeadStyle = {
964
+ display: "flex",
965
+ alignItems: "baseline",
966
+ gap: "var(--dtl-space-2)",
967
+ margin: "var(--dtl-space-4) 0 var(--dtl-space-2)",
968
+ fontSize: "var(--dtl-font-lg)",
969
+ fontWeight: 600
970
+ };
971
+ const groupHeadStyle = {
972
+ display: "flex",
973
+ alignItems: "center",
974
+ gap: "var(--dtl-space-2)",
975
+ width: "100%",
976
+ padding: "var(--dtl-space-2) var(--dtl-space-3)",
977
+ border: 0,
978
+ borderRadius: "var(--dtl-radius-md)",
979
+ background: "var(--dtl-surface-2)",
980
+ color: "inherit",
981
+ font: "inherit",
982
+ textAlign: "left",
983
+ cursor: "pointer"
984
+ };
985
+ const groupCountStyle = {
986
+ fontSize: "var(--dtl-font-xs)",
987
+ color: "var(--dtl-fg-3)",
988
+ fontWeight: 400
989
+ };
990
+ /** 分组默认折叠阈值(组内太多条时先收起,用户点开)。 */
991
+ const GROUP_COLLAPSE_OVER = 30;
745
992
  function BoardHome(props) {
746
993
  const { t, onBack } = props;
747
994
  ensureUiBase();
@@ -751,27 +998,47 @@ body[data-ds-dark-theme]{
751
998
  refresh();
752
999
  }, []);
753
1000
  const board = state.board;
754
- const rows = (0, react.useMemo)(() => selectRows(state), [state]);
755
- const tagOptions = (0, react.useMemo)(() => [{
756
- value: "",
757
- label: t("tagAll")
758
- }, ...(state.filters?.tags ?? []).map((tag) => ({
759
- value: tag,
760
- label: `#${tag}`
761
- }))], [state.filters, t]);
762
- /** 统计卡 → 筛选联动(点第二下不反选:档位语义是「切到这一档」,不是开关)。 */
763
- const card = (label, value, bucket, range) => (0, react.createElement)(StatCard, {
1001
+ const view = (0, react.useMemo)(() => selectView(state), [state]);
1002
+ const stats = board?.stats;
1003
+ const days = (value) => value === null || value === void 0 ? t("daysNone") : t("daysValue", { n: value });
1004
+ /** 统计卡 → 筛选联动(卡即入口:点一下跳到对应视角)。 */
1005
+ const card = (label, value, hint, patch) => (0, react.createElement)(StatCard, {
764
1006
  label: t(label),
765
1007
  value,
766
- active: state.query.bucket === bucket && (bucket !== "closed" || state.query.range === range),
1008
+ hint,
1009
+ active: isActive(state.query, patch),
767
1010
  onClick: () => {
768
- setQuery({
769
- bucket,
770
- range,
771
- limit: 500
772
- });
1011
+ setQuery(patch);
773
1012
  }
774
1013
  });
1014
+ /** 统计台卡:不可点(首页没有已结案可筛),hover 给具体日期区间。 */
1015
+ const periodCard = (label, period) => (0, react.createElement)(StatCard, {
1016
+ label: t(label),
1017
+ value: period?.done ?? 0,
1018
+ hint: t("statCreatedHint", { n: period?.created ?? 0 }),
1019
+ interactive: false,
1020
+ title: period === void 0 ? void 0 : t("periodRange", {
1021
+ from: period.from,
1022
+ to: period.to
1023
+ })
1024
+ });
1025
+ const [chartScope, setChartScope] = (0, react.useState)("all");
1026
+ const chartWeeks = (0, react.useMemo)(() => (board === null ? [] : board.closed.weekly[chartScope]).map((bucket) => ({
1027
+ label: bucket.week.slice(5),
1028
+ done: bucket.done,
1029
+ created: bucket.created,
1030
+ title: t("chartBucket", {
1031
+ week: bucket.week,
1032
+ done: bucket.done,
1033
+ created: bucket.created
1034
+ })
1035
+ })), [
1036
+ board,
1037
+ chartScope,
1038
+ t
1039
+ ]);
1040
+ const chartDoneTotal = chartWeeks.reduce((sum, week) => sum + week.done, 0);
1041
+ const chartEmpty = chartWeeks.every((week) => week.done === 0 && week.created === 0);
775
1042
  return (0, react.createElement)("div", { style: pageStyle }, (0, react.createElement)("div", { style: {
776
1043
  display: "flex",
777
1044
  justifyContent: "center"
@@ -785,7 +1052,7 @@ body[data-ds-dark-theme]{
785
1052
  alignItems: "center",
786
1053
  gap: "var(--dtl-space-3)",
787
1054
  minWidth: 0
788
- } }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1055
+ } }, (0, react.createElement)(Button, {
789
1056
  variant: "outline",
790
1057
  size: "md",
791
1058
  onClick: onBack
@@ -793,11 +1060,11 @@ body[data-ds-dark-theme]{
793
1060
  display: "flex",
794
1061
  alignItems: "center",
795
1062
  gap: "var(--dtl-space-2)"
796
- } }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.SegmentedControl, {
1063
+ } }, (0, react.createElement)(Segmented, {
797
1064
  id: "dtl-tab",
798
1065
  value: tab,
799
1066
  label: t("panelTitle"),
800
- options: [{
1067
+ items: [{
801
1068
  value: "tasks",
802
1069
  label: t("tabTasks")
803
1070
  }, {
@@ -805,95 +1072,157 @@ body[data-ds-dark-theme]{
805
1072
  label: t("tabStats")
806
1073
  }],
807
1074
  onChange: setTab
808
- }), (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1075
+ }), (0, react.createElement)(Button, {
809
1076
  variant: "outline",
810
1077
  size: "md",
811
1078
  icon: (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, { size: 16 }),
812
- title: t("refresh"),
813
1079
  disabled: state.refreshing,
1080
+ title: t("refresh"),
814
1081
  onClick: () => {
815
1082
  refresh();
816
1083
  }
817
- }, state.refreshing ? t("refreshing") : t("refresh")))), tab === "stats" ? (0, react.createElement)("div", { style: hintStyle }, t("statsPlaceholder")) : (0, react.createElement)("div", null, board === null ? null : (0, react.createElement)("div", { style: statsGridStyle }, card("statMyActive", String(board.stats.myActive), "mine", "all"), card("statOpen", String(board.stats.open), "open", "all"), card("statWeekDone", String(board.closed.weekDone), "closed", "week"), card("statYearDone", board.closed.yearCapped ? t("countCapped") : String(board.closed.yearDone), "closed", "year")), (0, react.createElement)("div", { style: filterRowStyle }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.SegmentedControl, {
818
- id: "dtl-bucket",
819
- value: state.query.bucket,
820
- label: t("bucketLabel"),
821
- options: [
822
- {
823
- value: "all",
824
- label: t("bucketAll")
825
- },
826
- {
827
- value: "mine",
828
- label: t("bucketMine")
829
- },
830
- {
831
- value: "open",
832
- label: t("bucketOpen")
833
- },
834
- {
835
- value: "closed",
836
- label: t("bucketClosed")
837
- }
838
- ],
839
- onChange: (next) => {
1084
+ }, state.refreshing ? t("refreshing") : t("refresh")))), tab === "stats" ? (0, react.createElement)("div", { style: hintStyle }, t("statsPlaceholder")) : (0, react.createElement)("div", null, stats === void 0 ? null : (0, react.createElement)("div", { style: cardsGridStyle }, card("statMyActive", stats.myActive, t("statMyActiveHint", { n: stats.myBlocked }), {
1085
+ scope: "mine",
1086
+ status: "all",
1087
+ workspace: ""
1088
+ }), card("statOpen", stats.open, t("statOpenHint", { n: days(stats.oldestOpenDays) }), {
1089
+ scope: "all",
1090
+ status: "open",
1091
+ workspace: ""
1092
+ }), card("statDoing", stats.doing, t("statDoingHint", { n: stats.doing - stats.myActive + stats.myBlocked }), {
1093
+ scope: "all",
1094
+ status: "active",
1095
+ workspace: ""
1096
+ }), (0, react.createElement)(StatCard, {
1097
+ label: t("statBlockedStale"),
1098
+ value: stats.blocked,
1099
+ hint: stats.stale === 0 ? void 0 : (0, react.createElement)("span", null, (0, react.createElement)("span", { style: {
1100
+ color: "var(--dtl-danger)",
1101
+ fontWeight: 600
1102
+ } }, t("statStaleWord")), ` ${t("statStaleHint", { n: stats.stale })}`),
1103
+ active: isActive(state.query, {
1104
+ scope: "all",
1105
+ status: "blocked",
1106
+ workspace: ""
1107
+ }),
1108
+ title: t("statStaleHint", { n: stats.staleDays }),
1109
+ onClick: () => {
840
1110
  setQuery({
841
- bucket: next,
842
- limit: 500
1111
+ scope: "all",
1112
+ status: "blocked",
1113
+ workspace: ""
843
1114
  });
844
1115
  }
845
- }), (0, react.createElement)(Select, {
846
- value: state.query.range,
847
- ariaLabel: t("rangeLabel"),
848
- options: [
1116
+ })), (0, react.createElement)("div", { style: regionCaptionStyle }, t("tabStats")), (0, react.createElement)("div", { style: statsRowStyle }, (0, react.createElement)("div", { style: statsCardsStyle }, periodCard("statWeekDone", board?.trend.thisWeek), periodCard("statLastWeekDone", board?.trend.lastWeek), periodCard("statMonthDone", board?.trend.thisMonth), periodCard("statLastMonthDone", board?.trend.lastMonth)), (0, react.createElement)("div", {
1117
+ className: "dtl-card",
1118
+ style: chartCardStyle
1119
+ }, (0, react.createElement)("div", { style: {
1120
+ display: "flex",
1121
+ alignItems: "center",
1122
+ justifyContent: "space-between",
1123
+ gap: "var(--dtl-space-2)"
1124
+ } }, (0, react.createElement)("span", { style: {
1125
+ fontSize: "var(--dtl-font-md)",
1126
+ fontWeight: 600
1127
+ } }, t("chartTitle")), (0, react.createElement)(Segmented, {
1128
+ id: "dtl-chart-scope",
1129
+ value: chartScope,
1130
+ label: t("scopeLabel"),
1131
+ size: "sm",
1132
+ variant: "inset",
1133
+ items: [{
1134
+ value: "all",
1135
+ label: t("chartScopeAll")
1136
+ }, {
1137
+ value: "mine",
1138
+ label: t("chartScopeMine")
1139
+ }],
1140
+ onChange: setChartScope
1141
+ })), chartEmpty ? (0, react.createElement)("div", { style: {
1142
+ ...hintStyle,
1143
+ padding: "var(--dtl-space-4) 0"
1144
+ } }, t("chartEmpty")) : (0, react.createElement)(GroupedBarChart, {
1145
+ weeks: chartWeeks,
1146
+ ariaLabel: t("chartTitle"),
1147
+ avgLabel: t("chartAvg", { n: Math.round(chartDoneTotal / Math.max(1, chartWeeks.length) * 10) / 10 })
1148
+ }), (0, react.createElement)(ChartLegend, {
1149
+ doneLabel: t("chartLegendDone"),
1150
+ createdLabel: t("chartLegendCreated")
1151
+ }), (0, react.createElement)("div", { style: {
1152
+ fontSize: "var(--dtl-font-xs)",
1153
+ color: "var(--dtl-fg-3)"
1154
+ } }, t("chartCaption")))), (0, react.createElement)("div", { style: filterRowStyle }, (0, react.createElement)(Segmented, {
1155
+ id: "dtl-scope",
1156
+ value: state.query.scope,
1157
+ label: t("scopeLabel"),
1158
+ items: [{
1159
+ value: "mine",
1160
+ label: t("scopeMine")
1161
+ }, {
1162
+ value: "all",
1163
+ label: t("scopeAll")
1164
+ }],
1165
+ onChange: (next) => {
1166
+ setQuery({ scope: next });
1167
+ }
1168
+ }), (0, react.createElement)(Segmented, {
1169
+ id: "dtl-status",
1170
+ value: state.query.status,
1171
+ label: t("statusLabel"),
1172
+ items: [
849
1173
  {
850
1174
  value: "all",
851
- label: t("rangeAll")
1175
+ label: t("statusAll")
852
1176
  },
853
1177
  {
854
- value: "week",
855
- label: t("rangeWeek")
1178
+ value: "active",
1179
+ label: t("statusActive")
856
1180
  },
857
1181
  {
858
- value: "month",
859
- label: t("rangeMonth")
1182
+ value: "open",
1183
+ label: t("statusOpen")
860
1184
  },
861
1185
  {
862
- value: "year",
863
- label: t("rangeYear")
1186
+ value: "blocked",
1187
+ label: t("statusBlocked")
1188
+ },
1189
+ {
1190
+ value: "stale",
1191
+ label: t("statusStale")
864
1192
  }
865
1193
  ],
866
1194
  onChange: (next) => {
867
- setQuery({
868
- range: next,
869
- limit: 500
870
- });
1195
+ setQuery({ status: next });
871
1196
  }
872
1197
  }), (0, react.createElement)(Select, {
873
- value: state.query.tag,
874
- ariaLabel: t("tagAll"),
875
- options: tagOptions,
1198
+ value: state.query.workspace,
1199
+ ariaLabel: t("workspaceLabel"),
1200
+ width: 180,
1201
+ options: [{
1202
+ value: "",
1203
+ label: t("workspaceAll")
1204
+ }, ...(board?.workspaces ?? []).map((workspace) => ({
1205
+ value: workspace.id,
1206
+ label: workspace.title
1207
+ }))],
876
1208
  onChange: (next) => {
877
- setQuery({
878
- tag: next,
879
- limit: 500
880
- });
1209
+ setQuery({ workspace: next });
881
1210
  }
882
- }), (0, react.createElement)(SearchInput, {
1211
+ }), (0, react.createElement)("div", { style: searchWrapStyle }, (0, react.createElement)(SearchInput, {
883
1212
  value: state.query.keyword,
884
1213
  placeholder: t("searchPlaceholder"),
885
1214
  ariaLabel: t("searchPlaceholder"),
886
1215
  onChange: (next) => {
887
1216
  setQuery({ keyword: next });
888
1217
  }
889
- })), state.status === "loading" && board === null ? null : state.status === "error" && board === null ? (0, react.createElement)("div", { style: {
1218
+ }))), state.status === "loading" && board === null ? null : state.status === "error" && board === null ? (0, react.createElement)("div", { style: {
890
1219
  display: "flex",
891
1220
  alignItems: "center",
892
1221
  gap: "var(--dtl-space-2)"
893
1222
  } }, (0, react.createElement)("span", { style: {
894
1223
  color: "var(--dtl-danger)",
895
1224
  fontSize: "var(--dtl-font-md)"
896
- } }, `${t("loadFailed")}:${state.error ?? ""}`), (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1225
+ } }, `${t("loadFailed")}:${state.error ?? ""}`), (0, react.createElement)(Button, {
897
1226
  variant: "outline",
898
1227
  size: "sm",
899
1228
  onClick: () => {
@@ -903,182 +1232,232 @@ body[data-ds-dark-theme]{
903
1232
  tone: "guide",
904
1233
  title: t("emptyNoWorkspace"),
905
1234
  hint: t("emptyNoWorkspaceHint")
906
- }) : state.query.bucket === "closed" && state.historyStatus === "loading" && state.history.length === 0 ? (0, react.createElement)("div", { style: hintStyle }, t("loading")) : state.query.bucket === "closed" && state.historyStatus === "error" ? (0, react.createElement)("div", { style: {
907
- display: "flex",
908
- alignItems: "center",
909
- gap: "var(--dtl-space-2)"
910
- } }, (0, react.createElement)("span", { style: {
911
- color: "var(--dtl-danger)",
912
- fontSize: "var(--dtl-font-md)"
913
- } }, `${t("loadFailed")}:${state.historyError ?? ""}`), (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
914
- variant: "outline",
915
- size: "sm",
916
- onClick: () => {
917
- setQuery({});
918
- }
919
- }, t("retry"))) : rows.length === 0 ? (0, react.createElement)(EmptyState, {
920
- tone: isPristine(state) ? "guide" : "filtered",
921
- title: isPristine(state) ? t("emptyNoTask") : t("emptyFiltered"),
922
- hint: isPristine(state) ? t("emptyNoTaskHint") : void 0
923
- }) : (0, react.createElement)("div", null, rows.map((row) => (0, react.createElement)(TaskRowView, {
1235
+ }) : view.total === 0 ? (0, react.createElement)(EmptyState, {
1236
+ tone: isPristine(state.query) ? "guide" : "filtered",
1237
+ title: isPristine(state.query) ? t("emptyNoTask") : t("emptyFiltered"),
1238
+ hint: isPristine(state.query) ? t("emptyNoTaskHint") : void 0
1239
+ }) : (0, react.createElement)("div", null, view.mine.length === 0 ? null : (0, react.createElement)("div", null, (0, react.createElement)("div", { style: regionHeadStyle }, (0, react.createElement)("span", null, `${t("regionMine")} ${view.mine.length}`), (0, react.createElement)("span", { style: {
1240
+ fontSize: "var(--dtl-font-xs)",
1241
+ color: "var(--dtl-fg-3)",
1242
+ fontWeight: 400
1243
+ } }, t("regionMineHint"))), view.mine.map((row) => (0, react.createElement)(TaskRowView, {
924
1244
  key: row.key,
925
1245
  t,
926
- row
927
- })), (0, react.createElement)("div", { style: {
928
- ...filterRowStyle,
929
- marginTop: "var(--dtl-space-3)",
1246
+ row,
1247
+ showWorkspace: true
1248
+ }))), view.groups.length === 0 ? null : (0, react.createElement)("div", null, (0, react.createElement)("div", { style: regionHeadStyle }, (0, react.createElement)("span", null, t("regionWorkspaces")), (0, react.createElement)("span", { style: {
1249
+ fontSize: "var(--dtl-font-xs)",
930
1250
  color: "var(--dtl-fg-3)",
931
- fontSize: "var(--dtl-font-xs)"
932
- } }, (0, react.createElement)("span", null, t("showingCount", { n: rows.length })), state.query.bucket === "closed" && state.history.length >= state.query.limit ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
933
- variant: "outline",
934
- size: "sm",
935
- disabled: state.historyStatus === "loading",
1251
+ fontWeight: 400
1252
+ } }, t("showingCount", { n: view.total - view.mine.length }))), view.groups.map((group) => (0, react.createElement)(WorkspaceGroupView, {
1253
+ key: group.workspaceId,
1254
+ t,
1255
+ group
1256
+ }))))))));
1257
+ }
1258
+ /** 一个工作区分组:标题行(可点折叠)+ 行列表。 */
1259
+ function WorkspaceGroupView(props) {
1260
+ const { t, group } = props;
1261
+ const [open, setOpen] = (0, react.useState)(group.rows.length <= GROUP_COLLAPSE_OVER);
1262
+ return (0, react.createElement)("div", { style: { marginBottom: "var(--dtl-space-3)" } }, (0, react.createElement)("button", {
1263
+ type: "button",
1264
+ className: "dtl-row-head",
1265
+ style: groupHeadStyle,
1266
+ "aria-expanded": open,
1267
+ title: open ? t("collapseAria") : t("expandAria"),
936
1268
  onClick: () => {
937
- loadMoreHistory();
1269
+ setOpen(!open);
938
1270
  }
939
- }, state.historyStatus === "loading" ? t("loading") : t("loadMore")) : null))))));
1271
+ }, (0, react.createElement)("span", {
1272
+ className: "dtl-ellipsis",
1273
+ style: {
1274
+ flex: "1 1 auto",
1275
+ fontSize: "var(--dtl-font-md)",
1276
+ fontWeight: 600
1277
+ }
1278
+ }, group.title), (0, react.createElement)("span", { style: groupCountStyle }, t("groupHint", {
1279
+ doing: group.doingCount,
1280
+ open: group.openCount
1281
+ }))), open ? (0, react.createElement)("div", { style: { marginTop: "var(--dtl-space-2)" } }, group.rows.map((row) => (0, react.createElement)(TaskRowView, {
1282
+ key: row.key,
1283
+ t,
1284
+ row
1285
+ }))) : null);
1286
+ }
1287
+ /** 卡片高亮:当前 query 是否正好落在该卡对应的视角上。 */
1288
+ function isActive(query, patch) {
1289
+ return Object.entries(patch).every(([key, value]) => query[key] === value);
940
1290
  }
941
1291
  /** 是否「什么都没筛」:真空才给引导卡,一有筛选就只能是「筛选空」。 */
942
- function isPristine(state) {
943
- const { query } = state;
944
- return query.bucket === "all" && query.range === "all" && query.tag === "" && query.keyword.trim() === "";
1292
+ function isPristine(query) {
1293
+ return query.scope === "all" && query.status === "all" && query.workspace === "" && query.keyword.trim() === "";
945
1294
  }
946
1295
  //#endregion
947
1296
  //#region src/client/locales.ts
948
- /** 把宿主给的无参 t 包成带占位符替换的 t(官方模板一律 `{name}`)。 */
949
- function interpolateTranslate(base) {
950
- return (key, params) => {
951
- const raw = base(key);
952
- if (params === void 0) return raw;
953
- return raw.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
954
- };
955
- }
1297
+ /** 中文(默认档)。 */
956
1298
  const zh = {
957
- panelTitle: "任务台账",
958
1299
  backToConversation: "返回会话",
1300
+ panelTitle: "任务台账",
959
1301
  tabTasks: "任务",
960
1302
  tabStats: "统计",
961
- statsPlaceholder: "统计报表尚未开建(本版只有任务首页)",
962
- statMyActive: "我的待办",
963
- statOpen: "待认领",
964
- statWeekDone: "本周已完成",
965
- statYearDone: "近一年完成",
966
- countCapped: "1万+",
967
- bucketLabel: "状态档",
968
- bucketAll: "全部",
969
- bucketMine: "我的待办",
970
- bucketOpen: "待认领",
971
- bucketClosed: "已结案",
972
- rangeLabel: "时间",
973
- rangeAll: "全部时间",
974
- rangeWeek: "本周",
975
- rangeMonth: "本月",
976
- rangeYear: "近一年",
977
- tagAll: "全部标签",
978
- searchPlaceholder: "搜索标题 / 负责人 / 任务号",
979
1303
  refresh: "刷新",
980
- refreshing: "读取中…",
981
- loading: "加载中…",
1304
+ refreshing: "刷新中",
1305
+ loading: "读取中…",
982
1306
  retry: "重试",
983
1307
  loadFailed: "读取失败",
984
- emptyNoWorkspace: "没有检测到台账工作区",
985
- emptyNoWorkspaceHint: "宿主已添加的工作区里,还没有含 ledger/progress.json 的目录。",
986
- emptyNoTask: "还没有任务",
987
- emptyNoTaskHint: "在任一工作区里用 task-ledger skill 建任务(/ledger),这里就会看到它。",
988
- emptyFiltered: "当前筛选下没有任务",
1308
+ statsPlaceholder: "统计报表尚未开建(另立任务):这里以后放分类分布、按人完成数、时间线。",
1309
+ statMyActive: "我的待办",
1310
+ statMyActiveHint: "其中阻塞 {n}",
1311
+ statOpen: "待认领",
1312
+ statOpenHint: "最老一条躺了 {n}",
1313
+ statDoing: "进行中",
1314
+ statDoingHint: "别人在做 {n}",
1315
+ statBlockedStale: "阻塞 · 停滞",
1316
+ statStaleWord: "停滞",
1317
+ statStaleHint: "{n} 条",
1318
+ statWeekDone: "本周完成",
1319
+ statLastWeekDone: "上周完成",
1320
+ statMonthDone: "本月完成",
1321
+ statLastMonthDone: "上月完成",
1322
+ statCreatedHint: "新增 {n} 条",
1323
+ daysNone: "—",
1324
+ daysValue: "{n} 天",
1325
+ chartTitle: "近 8 周",
1326
+ chartCaption: "完成(按结案时间)/ 新增(按创建时间),周一起桶;我的 = owner 是我",
1327
+ chartBucket: "{week} 那一周:完成 {done} 条,新增 {created} 条",
1328
+ chartEmpty: "近 8 周没有记录",
1329
+ chartScopeAll: "所有",
1330
+ chartScopeMine: "我的",
1331
+ chartLegendDone: "完成",
1332
+ chartLegendCreated: "新增",
1333
+ chartAvg: "周均 {n}",
1334
+ periodRange: "{from} ~ {to}",
1335
+ scopeLabel: "范围",
1336
+ scopeMine: "我的",
1337
+ scopeAll: "全部工作区",
1338
+ statusLabel: "状态",
1339
+ statusAll: "全部",
1340
+ statusActive: "进行中",
1341
+ statusOpen: "待认领",
1342
+ statusBlocked: "阻塞",
1343
+ statusStale: "停滞",
1344
+ workspaceLabel: "工作区",
1345
+ workspaceAll: "全部工作区",
1346
+ searchPlaceholder: "搜索标题 / 任务号 / 人 / 分类 / 标签",
1347
+ regionMine: "我的待办",
1348
+ regionMineHint: "跨工作区,按创建时间倒序",
1349
+ regionWorkspaces: "按工作区",
1350
+ groupHint: "进行中 {doing} | 待认领 {open}",
989
1351
  showingCount: "显示 {n} 条",
990
- loadMore: "加载更多",
991
- noMore: "没有更多了",
992
- expandAria: "展开详情",
993
- collapseAria: "收起详情",
994
- fieldOwner: "负责人",
995
- fieldCreator: "创建人",
1352
+ expandAria: "展开过程文档",
1353
+ collapseAria: "收起",
1354
+ ownerUnclaimed: "待认领",
996
1355
  fieldCreated: "创建",
997
1356
  fieldStarted: "开始",
998
1357
  fieldFinished: "结案",
999
- fieldWorkspace: "工作区",
1000
- fieldCategory: "分类",
1001
- ownerUnclaimed: "未认领",
1002
1358
  timeUnknown: "时间未知",
1003
- statusOpen: "待认领",
1004
- statusDoing: "进行中",
1005
- statusBlocked: "受阻",
1006
- statusClosed: "已结案",
1007
1359
  worklogTitle: "过程文档",
1008
- worklogMissing: "过程文档缺失(文件已不在盘上)",
1009
1360
  worklogPath: "路径",
1010
- badgeTitle: "我名下进行中 + 受阻的任务数",
1011
- openBoard: "打开任务台账",
1361
+ worklogMissing: "(缺失)",
1012
1362
  mdCopy: "复制",
1013
1363
  mdCopied: "已复制",
1014
- mdFootnotes: "脚注"
1364
+ mdFootnotes: "脚注",
1365
+ emptyNoWorkspace: "还没有台账工作区",
1366
+ emptyNoWorkspaceHint: "在某个项目里跑 task-ledger skill 的 init 建档(生成 ledger/progress.json),这里就会出现它。",
1367
+ emptyNoTask: "一条任务都没有",
1368
+ emptyNoTaskHint: "用 task-ledger skill 的 new 开一个任务,或让别人开在这里等你认领。",
1369
+ emptyFiltered: "没有符合当前筛选的任务",
1370
+ badgeTitle: "我名下待办",
1371
+ openBoard: "打开任务台账"
1015
1372
  };
1373
+ /** English(与 zh 键完全对齐)。 */
1016
1374
  const en = {
1375
+ backToConversation: "Back to chat",
1017
1376
  panelTitle: "Task ledger",
1018
- backToConversation: "Back to conversation",
1019
1377
  tabTasks: "Tasks",
1020
1378
  tabStats: "Stats",
1021
- statsPlaceholder: "The stats report is not built yet (this release ships the task home only)",
1022
- statMyActive: "My active",
1023
- statOpen: "Unclaimed",
1024
- statWeekDone: "Closed this week",
1025
- statYearDone: "Closed in the last year",
1026
- countCapped: "10k+",
1027
- bucketLabel: "Status",
1028
- bucketAll: "All",
1029
- bucketMine: "My active",
1030
- bucketOpen: "Unclaimed",
1031
- bucketClosed: "Closed",
1032
- rangeLabel: "Time",
1033
- rangeAll: "Any time",
1034
- rangeWeek: "This week",
1035
- rangeMonth: "This month",
1036
- rangeYear: "Last year",
1037
- tagAll: "All tags",
1038
- searchPlaceholder: "Search title / owner / task id",
1039
1379
  refresh: "Refresh",
1040
- refreshing: "Loading…",
1380
+ refreshing: "Refreshing",
1041
1381
  loading: "Loading…",
1042
1382
  retry: "Retry",
1043
- loadFailed: "Failed to load",
1044
- emptyNoWorkspace: "No ledger workspace detected",
1045
- emptyNoWorkspaceHint: "None of the workspaces added to the host contains ledger/progress.json.",
1046
- emptyNoTask: "No tasks yet",
1047
- emptyNoTaskHint: "Create one with the task-ledger skill (/ledger) in any workspace and it shows up here.",
1048
- emptyFiltered: "No task matches the current filters",
1049
- showingCount: "Showing {n}",
1050
- loadMore: "Load more",
1051
- noMore: "No more",
1052
- expandAria: "Expand details",
1053
- collapseAria: "Collapse details",
1054
- fieldOwner: "Owner",
1055
- fieldCreator: "Creator",
1056
- fieldCreated: "Created",
1057
- fieldStarted: "Started",
1058
- fieldFinished: "Closed",
1059
- fieldWorkspace: "Workspace",
1060
- fieldCategory: "Category",
1061
- ownerUnclaimed: "unclaimed",
1062
- timeUnknown: "unknown time",
1383
+ loadFailed: "Load failed",
1384
+ statsPlaceholder: "Stats reports are not built yet (separate task): category breakdown, completions by person, timeline.",
1385
+ statMyActive: "My todos",
1386
+ statMyActiveHint: "{n} blocked",
1387
+ statOpen: "Unclaimed",
1388
+ statOpenHint: "oldest waiting {n}",
1389
+ statDoing: "In progress",
1390
+ statDoingHint: "{n} by others",
1391
+ statBlockedStale: "Blocked · Stale",
1392
+ statStaleWord: "Stale",
1393
+ statStaleHint: "{n}",
1394
+ statWeekDone: "Done this week",
1395
+ statLastWeekDone: "Done last week",
1396
+ statMonthDone: "Done this month",
1397
+ statLastMonthDone: "Done last month",
1398
+ statCreatedHint: "{n} created",
1399
+ daysNone: "—",
1400
+ daysValue: "{n}d",
1401
+ chartTitle: "Last 8 weeks",
1402
+ chartCaption: "done (by close time) / created (by create time), weeks start Monday; mine = owner is me",
1403
+ chartBucket: "week of {week}: {done} done, {created} created",
1404
+ chartEmpty: "Nothing in the last 8 weeks",
1405
+ chartScopeAll: "All",
1406
+ chartScopeMine: "Mine",
1407
+ chartLegendDone: "Done",
1408
+ chartLegendCreated: "Created",
1409
+ chartAvg: "avg {n}/wk",
1410
+ periodRange: "{from} ~ {to}",
1411
+ scopeLabel: "Scope",
1412
+ scopeMine: "Mine",
1413
+ scopeAll: "All workspaces",
1414
+ statusLabel: "Status",
1415
+ statusAll: "All",
1416
+ statusActive: "Active",
1063
1417
  statusOpen: "Unclaimed",
1064
- statusDoing: "In progress",
1065
1418
  statusBlocked: "Blocked",
1066
- statusClosed: "Closed",
1067
- worklogTitle: "Process log",
1068
- worklogMissing: "Process log is missing (the file is no longer on disk)",
1069
- worklogPath: "Path",
1070
- badgeTitle: "Tasks I own that are in progress or blocked",
1071
- openBoard: "Open the task ledger",
1419
+ statusStale: "Stale",
1420
+ workspaceLabel: "Workspace",
1421
+ workspaceAll: "All workspaces",
1422
+ searchPlaceholder: "Search title / id / person / category / tag",
1423
+ regionMine: "My todos",
1424
+ regionMineHint: "across workspaces, newest first",
1425
+ regionWorkspaces: "By workspace",
1426
+ groupHint: "{doing} active | {open} unclaimed",
1427
+ showingCount: "{n} shown",
1428
+ expandAria: "Expand worklog",
1429
+ collapseAria: "Collapse",
1430
+ ownerUnclaimed: "unclaimed",
1431
+ fieldCreated: "created",
1432
+ fieldStarted: "started",
1433
+ fieldFinished: "closed",
1434
+ timeUnknown: "unknown time",
1435
+ worklogTitle: "Worklog",
1436
+ worklogPath: "path",
1437
+ worklogMissing: "(missing)",
1072
1438
  mdCopy: "Copy",
1073
1439
  mdCopied: "Copied",
1074
- mdFootnotes: "Footnotes"
1075
- };
1076
- /** 宿主未提供 t 时的兜底(保持中文:宿主语言服务缺席不该让界面变键名)。 */
1077
- const fallbackTranslate = (key, params) => {
1078
- const raw = zh[key];
1079
- if (params === void 0) return raw;
1080
- return raw.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
1440
+ mdFootnotes: "Footnotes",
1441
+ emptyNoWorkspace: "No ledger workspace yet",
1442
+ emptyNoWorkspaceHint: "Run the task-ledger skill init in a project (creates ledger/progress.json) and it shows up here.",
1443
+ emptyNoTask: "No tasks yet",
1444
+ emptyNoTaskHint: "Create one with the task-ledger skill new, or leave one here for others to claim.",
1445
+ emptyFiltered: "No tasks match the current filters",
1446
+ badgeTitle: "my todos",
1447
+ openBoard: "Open task ledger"
1081
1448
  };
1449
+ /** 把宿主给的无参 t 包成带占位符替换的 t(模板一律 `{name}`,不依赖宿主是否支持 params)。 */
1450
+ function interpolateTranslate(base) {
1451
+ return (key, params) => {
1452
+ const raw = base(key);
1453
+ if (params === void 0) return raw;
1454
+ return raw.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
1455
+ };
1456
+ }
1457
+ /** 键名兜底:宿主没给 t 席位时用(键名绝不上界面)。 */
1458
+ function fallbackTranslate(key, params) {
1459
+ return interpolateTranslate((name) => zh[name] ?? name)(key, params);
1460
+ }
1082
1461
  //#endregion
1083
1462
  //#region src/client/overlay-entry.ts
1084
1463
  /**