dsh-worktodo 0.1.3 → 0.2.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/lib/index.js CHANGED
@@ -123,6 +123,27 @@ function readJsonBody(request) {
123
123
  * order 取 3050:官方 SECTION_ORDERS 里内置工具段是 1000–3000、MCP_SERVERS 是 3100,
124
124
  * 插在两者之间读起来就是「插件工具的使用说明」。
125
125
  */
126
+ /**
127
+ * 版本与更新提示。
128
+ *
129
+ * 只查 npm registry 的 dist-tags.latest(不下载、不安装),结果缓存 6 小时,失败静默——
130
+ * 面板角落显示「当前版本」与「有新版本 vX.Y.Z」,绝不让网络问题影响面板。
131
+ * 测试里设 WORKTODO_NO_UPDATE_CHECK=1 可完全关掉(单元测试不该打网络)。
132
+ */
133
+ const UPDATE_CHECK_OFF = process.env.WORKTODO_NO_UPDATE_CHECK === '1';
134
+ const UPDATE_TTL_MS = 6 * 3600 * 1000;
135
+ const PACKAGE_NAME = 'dsh-worktodo';
136
+
137
+ /** 语义化版本比较:a > b 返回正数。 */
138
+ function compareVersion(a, b) {
139
+ const pa = String(a).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
140
+ const pb = String(b).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
141
+ for (let i = 0; i < 3; i += 1) {
142
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
143
+ }
144
+ return 0;
145
+ }
146
+
126
147
  const GUIDANCE_TEXT = [
127
148
  '工作待办(worktodo_*):属于用户、跨会话持久的工作清单;侧栏面板与这些工具读写同一份数据。',
128
149
  '- 该用:用户说出要办的事、承诺或截止日期(「下周要交」「等对方回复」「记一下」),或你在工作中发现超出本会话、仍需人跟进的待办 → worktodo_add。',
@@ -179,6 +200,38 @@ export async function apply(ctx, config = {}) {
179
200
  }, 'worktodo: agent tools');
180
201
  });
181
202
 
203
+ // ---- 版本与更新(后台、不阻塞任何请求)----
204
+ let updateState = { at: 0, latest: null, error: null };
205
+ const refreshUpdate = async () => {
206
+ if (UPDATE_CHECK_OFF) return updateState;
207
+ const now = Date.now();
208
+ if (updateState.at && now - updateState.at < UPDATE_TTL_MS) return updateState;
209
+ updateState = { ...updateState, at: now }; // 先占位,避免并发重复请求
210
+ try {
211
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}`, {
212
+ headers: { accept: 'application/json' },
213
+ signal: AbortSignal.timeout(8000),
214
+ });
215
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
216
+ const body = await res.json();
217
+ const latest = String(body?.['dist-tags']?.latest ?? '');
218
+ updateState = { at: now, latest: /^\d+\.\d+\.\d+$/.test(latest) ? latest : null, error: null };
219
+ } catch (error) {
220
+ updateState = { at: now, latest: null, error: String(error?.message ?? error) };
221
+ }
222
+ return updateState;
223
+ };
224
+ const updatePayload = () => {
225
+ const latest = updateState.latest;
226
+ return {
227
+ current: PLUGIN_VERSION,
228
+ latest: latest ?? undefined,
229
+ hasUpdate: Boolean(latest) && !UPDATE_CHECK_OFF && compareVersion(latest, PLUGIN_VERSION) > 0,
230
+ page: `https://www.npmjs.com/package/${PACKAGE_NAME}`,
231
+ };
232
+ };
233
+ if (!UPDATE_CHECK_OFF) void refreshUpdate(); // 启动时后台查一次(失败静默)
234
+
182
235
  // ---- 给模型的用法指引(可选服务;缺 systemPrompt 时静默跳过)----
183
236
  ctx.inject(['systemPrompt'], (spCtx) => {
184
237
  spCtx.effect(() => {
@@ -251,13 +304,16 @@ export async function apply(ctx, config = {}) {
251
304
  path: `${ROUTE_BASE}/list`,
252
305
  handler: method('GET', async (request, response) => {
253
306
  const url = new URL(request.url ?? ROUTE_BASE, 'http://localhost');
307
+ if (!UPDATE_CHECK_OFF && Date.now() - updateState.at >= UPDATE_TTL_MS) void refreshUpdate();
254
308
  const result = await service.list({
255
309
  filter: url.searchParams.get('filter') ?? 'open',
256
310
  project: url.searchParams.get('project') ?? undefined,
257
311
  query: url.searchParams.get('query') ?? undefined,
258
312
  sort: url.searchParams.get('sort') ?? undefined,
259
313
  });
260
- reply(response, result);
314
+ reply(response, result.ok
315
+ ? { ...result, version: PLUGIN_VERSION, update: updatePayload() }
316
+ : result);
261
317
  }),
262
318
  }),
263
319
  // 写:手动排序(拖动)。body: { ids: [待办 id,按新顺序排列] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-worktodo",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "DSH 工作待办面板(社区插件,非官方):归你所有、跨会话持久的工作待办——优先级 / 截止日期 / 项目 / 逾期与本周视图,agent 通过 worktodo_* 工具读写同一份列表。官方 todo_write 属于单个会话且只能整表替换,这份列表属于人。Human-owned cross-session work todo list for DeepSeek Harness: priorities, due dates, projects, overdue/week views and worktodo_* tools the agent shares with you.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ // 与宿主共用同一份解析器:人机一致,且零依赖(会被 esbuild 内联进 bundle)
3
+ import { parseNaturalDue } from '../parse-due.mjs';
2
4
 
3
5
  /**
4
6
  * worktodo 浏览器半侧:DSH 侧栏入口 + 中栏「工作待办」面板。
@@ -71,6 +73,18 @@ const zh = {
71
73
  'sortPriority': '优先级',
72
74
  'sortCreated': '创建时间',
73
75
  'dragHint': '拖动调整顺序(会自动切到手动排序)',
76
+ 'versionLabel': '版本',
77
+ 'versionHint': '当前安装的 dsh-worktodo 版本',
78
+ 'updateAvailable': '有新版本',
79
+ 'updateHint': '更新命令(重启 DSH 生效)',
80
+ 'addOpen': '新增待办',
81
+ 'addCancel': '取消',
82
+ 'prioNone': '无',
83
+ 'dateUnset': '日期 · 未设',
84
+ 'timeUnset': '时间 · 未设',
85
+ 'todayWord': '今天',
86
+ 'fromParsed': '识别',
87
+ 'dueTimeLabel': '时间',
74
88
  'expand': '展开',
75
89
  'collapse': '收起',
76
90
  'empty': '还没有待办',
@@ -138,6 +152,18 @@ const en = {
138
152
  'sortPriority': 'Priority',
139
153
  'sortCreated': 'Created',
140
154
  'dragHint': 'Drag to reorder (switches to manual sort)',
155
+ 'versionLabel': 'Version',
156
+ 'versionHint': 'Installed dsh-worktodo version',
157
+ 'updateAvailable': 'Update available',
158
+ 'updateHint': 'Update command (restart DSH to apply)',
159
+ 'addOpen': 'New todo',
160
+ 'addCancel': 'Cancel',
161
+ 'prioNone': 'None',
162
+ 'dateUnset': 'Date · unset',
163
+ 'timeUnset': 'Time · unset',
164
+ 'todayWord': 'Today',
165
+ 'fromParsed': 'parsed',
166
+ 'dueTimeLabel': 'Time',
141
167
  'expand': 'Expand',
142
168
  'collapse': 'Collapse',
143
169
  'empty': 'No to-dos yet',
@@ -174,6 +200,13 @@ const en = {
174
200
  * 不写任何颜色字面量兜底——兜底值等于写死某一种主题下的颜色,另一套主题就花了。
175
201
  * 需要「淡淡的底色」时用 color-mix 把语义色混进 transparent,仍然是变量驱动。
176
202
  * ------------------------------------------------------------------ */
203
+ /** 本地时区的 YYYY-MM-DD(面板里判断"今天 / 逾期"用;与宿主 records.mjs 的 dayKey 同语义)。 */
204
+ function dayKey(stamp) {
205
+ const d = new Date(stamp);
206
+ const pad = (n) => String(n).padStart(2, '0');
207
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
208
+ }
209
+
177
210
  const C = {
178
211
  label: 'var(--dsw-alias-label-primary)',
179
212
  label2: 'var(--dsw-alias-label-secondary)',
@@ -256,6 +289,20 @@ const STYLE_TEXT = `
256
289
  .wt-seg-btn:hover { color: ${C.label}; }
257
290
  .wt-row[draggable="true"] { cursor: grab; }
258
291
  .wt-row[draggable="true"]:active { cursor: grabbing; }
292
+ .wt-plus {
293
+ width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
294
+ padding: 12px 14px; margin-bottom: 10px; cursor: pointer; font: inherit; font-size: 13.5px; font-weight: 600;
295
+ color: ${C.label2}; background: ${C.layer1};
296
+ border: 1px dashed ${C.border}; border-radius: 12px;
297
+ }
298
+ .wt-plus:hover { color: ${C.label}; border-color: ${C.brand}; background: ${tint(C.brand, 8)}; }
299
+ .wt-plus-mark { font-size: 24px; line-height: 1; font-weight: 400; color: ${C.brand}; }
300
+ .wt-flag-btn {
301
+ appearance: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
302
+ font: inherit; font-size: 11.5px; padding: 5px 9px; border-radius: 9px;
303
+ background: transparent; border: 1px solid ${C.border}; color: ${C.label2};
304
+ }
305
+ .wt-flag-btn.is-on { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, transparent); color: inherit; }
259
306
  .wt-group-head {
260
307
  display: flex; align-items: center; gap: 7px; padding: 3px 8px; border-radius: 8px;
261
308
  cursor: pointer; user-select: none; color: ${C.label2};
@@ -434,72 +481,220 @@ function useList(sort) {
434
481
  /* ------------------------------------------------------------------ *
435
482
  * 快速添加
436
483
  * ------------------------------------------------------------------ */
484
+ /** 小红旗图标:填充与描边都用 currentColor,颜色由按钮的 color 决定。 */
485
+
486
+ /**
487
+ * 新增待办:平时只是一个**大加号**,点开才展开表单。
488
+ * 日期用原生日历(`type=date`)、时间用原生时钟(`type=time`),
489
+ * 优先级用三个不同颜色的**小红旗**表示(高=红、普通=琥珀、低=灰)。
490
+ */
491
+
492
+ /** 小红旗图标:颜色由父级 color 决定;filled=选中档位。 */
493
+ function IconFlag({ filled, dashed }) {
494
+ return React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', 'aria-hidden': 'true' }, [
495
+ React.createElement('path', {
496
+ key: 'pole', d: 'M3.6 1.6v13', stroke: 'currentColor', strokeWidth: 1.6,
497
+ strokeLinecap: 'round', fill: 'none', strokeDasharray: dashed ? '1.5 1.5' : undefined,
498
+ }),
499
+ React.createElement('path', {
500
+ key: 'cloth', d: 'M5.2 2.4h7.4L11 5.2l1.6 2.8H5.2z',
501
+ fill: filled ? 'currentColor' : 'none', stroke: 'currentColor', strokeWidth: 1.3,
502
+ strokeLinejoin: 'round', strokeDasharray: dashed ? '1.5 1.5' : undefined,
503
+ }),
504
+ ]);
505
+ }
506
+
507
+ const IconCal = () => React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, 'aria-hidden': 'true' }, [
508
+ React.createElement('rect', { key: 'r', x: 2.2, y: 3.4, width: 11.6, height: 10.4, rx: 2 }),
509
+ React.createElement('path', { key: 'p', d: 'M2.2 6.6h11.6M5.4 2.2v2.4M10.6 2.2v2.4' }),
510
+ ]);
511
+ const IconClock = () => React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, 'aria-hidden': 'true' }, [
512
+ React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 5.8 }),
513
+ React.createElement('path', { key: 'p', d: 'M8 4.9V8l2.2 1.4' }),
514
+ ]);
515
+
516
+ /**
517
+ * 快速添加:两行布局(设计稿 v5)。
518
+ * 第一行:待办内容 + 添加;
519
+ * 第二行(更小):日期 / 时间 / 优先级——显示**当前值**,点开才选。
520
+ * 标题里写了「明天下午3点」这类说法会被实时解析:第二行自动点亮并标"· 识别",
521
+ * 且日期词会从标题里摘掉。**手动改过就不再被自动覆盖**(手动优先)。
522
+ */
437
523
  function QuickAdd({ t, onAdd, busy }) {
438
524
  const [title, setTitle] = React.useState('');
439
525
  const [due, setDue] = React.useState('');
440
- const [priority, setPriority] = React.useState('normal');
526
+ const [dueTime, setDueTime] = React.useState('');
527
+ const [priority, setPriority] = React.useState('none');
528
+ const [parsed, setParsed] = React.useState(null);
529
+ const [manual, setManual] = React.useState(false); // 用户手动改过日期/时间
530
+ const [flagOpen, setFlagOpen] = React.useState(false);
531
+ // 点"整块胶囊"都要弹出选择器:Safari 里带值的 date/time 输入光靠点击只会聚焦,
532
+ // 所以统一主动调 showPicker()(不可用时退回 focus+click)
533
+ const dateRef = React.useRef(null);
534
+ const timeRef = React.useRef(null);
535
+ const openPicker = (ref) => {
536
+ const el = ref.current;
537
+ if (!el) return;
538
+ if (typeof el.showPicker === 'function') {
539
+ try { el.showPicker(); return; } catch { /* 某些环境不允许,走下面的兜底 */ }
540
+ }
541
+ el.focus();
542
+ try { el.click(); } catch { /* 忽略 */ }
543
+ };
544
+
545
+ // 输入防抖 300ms 再解析(每按一键就解析会闪烁)
546
+ React.useEffect(() => {
547
+ if (manual) return undefined;
548
+ const text = title;
549
+ if (!text || !text.trim()) { setParsed(null); return undefined; }
550
+ const timer = setTimeout(() => {
551
+ const result = parseNaturalDue(text, Date.now());
552
+ if (result.matched.length) {
553
+ setParsed(result);
554
+ setDue(result.due ?? '');
555
+ setDueTime(result.dueTime ?? '');
556
+ } else {
557
+ setParsed(null);
558
+ }
559
+ }, 300);
560
+ return () => clearTimeout(timer);
561
+ }, [title, manual]);
441
562
 
442
563
  const submit = () => {
443
564
  if (busy) return;
444
- // 截止日期与优先级是「这一批」的选择,加完保留:同一天连着加几件事时不用反复填
445
- Promise.resolve(onAdd({ title, due, priority })).then((ok) => {
446
- if (ok !== false) setTitle('');
447
- }).catch(() => { /* onAdd 内部已经吞掉错误并显示在错误条上 */ });
565
+ let payloadTitle = title;
566
+ if (!manual) {
567
+ const result = parseNaturalDue(title, Date.now());
568
+ if (result.matched.length) payloadTitle = result.title; // 日期词摘掉
569
+ }
570
+ Promise.resolve(onAdd({
571
+ title: payloadTitle,
572
+ due: due || null,
573
+ dueTime: dueTime || null,
574
+ priority,
575
+ })).then((ok) => {
576
+ if (ok !== false) {
577
+ setTitle('');
578
+ setParsed(null);
579
+ setManual(false);
580
+ setFlagOpen(false);
581
+ // 日期/时间/优先级保留:连着录同一天的几件事不用重设
582
+ }
583
+ }).catch(() => { /* onAdd 内部已显示错误 */ });
448
584
  };
449
585
 
450
- return React.createElement('div', {
451
- style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 10 },
452
- }, [
453
- React.createElement('input', {
454
- key: 'ti',
455
- className: 'wt-input',
456
- style: { flex: '1 1 200px', fontSize: 12.5, padding: '5px 9px' },
457
- value: title,
458
- placeholder: t('addPlaceholder'),
459
- 'aria-label': t('addPlaceholder'),
460
- onChange: (event) => setTitle(event.target.value),
461
- onKeyDown: (event) => { if (event.key === 'Enter') submit(); },
462
- }),
463
- React.createElement('label', {
464
- key: 'due',
465
- title: t('dueLabel'),
466
- style: { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: C.label3 },
467
- }, [
468
- t('dueLabel'),
586
+ const today = dayKey(Date.now());
587
+ const dateLabel = due
588
+ ? (due === today ? t('todayWord') : `${due.slice(5).replace('-', '-')}`)
589
+ : t('dateUnset');
590
+ const timeLabel = dueTime || t('timeUnset');
591
+ const prioLabel = priority === 'high' ? t('prioHigh') : priority === 'normal' ? t('prioNormal') : priority === 'low' ? t('prioLow') : t('prioNone');
592
+ const prioColor = priority === 'high' ? C.error : priority === 'normal' ? C.warn : C.label3;
593
+
594
+ const flags = [
595
+ { value: 'none', label: t('prioNone'), color: C.label3, dashed: true },
596
+ { value: 'high', label: t('prioHigh'), color: C.error },
597
+ { value: 'normal', label: t('prioNormal'), color: C.warn },
598
+ { value: 'low', label: t('prioLow'), color: C.label3 },
599
+ ];
600
+
601
+ const setDate = (value) => {
602
+ setManual(true);
603
+ setDue(value);
604
+ if (!value) setDueTime(''); // 没日期就没时间
605
+ setFlagOpen(false);
606
+ };
607
+ const setTime = (value) => {
608
+ setManual(true);
609
+ if (value && !due) setDue(dayKey(Date.now())); // 只选时间=今天
610
+ setDueTime(value);
611
+ };
612
+
613
+ return React.createElement('div', { key: 'qa', style: { marginBottom: 12 } }, [
614
+ // ---- 第一行:内容 + 添加 ----
615
+ React.createElement('div', { key: 'l1', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
469
616
  React.createElement('input', {
470
- key: 'i',
617
+ key: 'ti',
471
618
  className: 'wt-input',
472
- type: 'date',
473
- value: due,
474
- 'aria-label': t('dueLabel'),
475
- onChange: (event) => setDue(event.target.value),
619
+ style: { flex: '1 1 auto', fontSize: 13, padding: '7px 10px' },
620
+ value: title,
621
+ placeholder: t('addPlaceholder'),
622
+ 'aria-label': t('addPlaceholder'),
623
+ onChange: (event) => {
624
+ const next = event.target.value;
625
+ setTitle(next);
626
+ // 手动优先,但把标题清空=重新开始:交回给自动识别
627
+ if (!next.trim()) { setManual(false); setParsed(null); }
628
+ },
629
+ onKeyDown: (event) => { if (event.key === 'Enter') submit(); },
476
630
  }),
631
+ React.createElement('button', {
632
+ key: 'add', type: 'button', className: 'wt-btn wt-btn-primary',
633
+ disabled: Boolean(busy), onClick: submit,
634
+ }, t('add')),
477
635
  ]),
478
- React.createElement('label', {
479
- key: 'prio',
480
- title: t('priorityLabel'),
481
- style: { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: C.label3 },
482
- }, [
483
- t('priorityLabel'),
484
- React.createElement('select', {
485
- key: 's',
486
- className: 'wt-input',
487
- value: priority,
488
- 'aria-label': t('priorityLabel'),
489
- onChange: (event) => setPriority(event.target.value),
636
+
637
+ // ---- 第二行(更小):日期 / 时间 / 优先级 ----
638
+ React.createElement('div', { key: 'l2', style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, fontSize: 11.5, flexWrap: 'wrap' } }, [
639
+ // 日期:label 里套一个透明的原生 date 输入,点它即弹系统日历
640
+ React.createElement('label', {
641
+ key: 'due',
642
+ className: `wt-chip${parsed && parsed.due ? ' is-parsed' : ''}`,
643
+ title: t('dueLabel'),
644
+ onClick: (event) => { event.preventDefault(); openPicker(dateRef); },
645
+ style: { position: 'relative', cursor: 'pointer', color: due ? C.label : C.label3, borderColor: parsed && parsed.due ? C.brand : undefined },
490
646
  }, [
491
- React.createElement('option', { key: 'high', value: 'high' }, t('prioHigh')),
492
- React.createElement('option', { key: 'normal', value: 'normal' }, t('prioNormal')),
493
- React.createElement('option', { key: 'low', value: 'low' }, t('prioLow')),
647
+ React.createElement(IconCal, { key: 'i' }),
648
+ React.createElement('span', { key: 'v' }, due ? `${today === due ? t('todayWord') : due}${parsed && parsed.due ? ` · ${t('fromParsed')}` : ''}` : t('dateUnset')),
649
+ React.createElement('input', {
650
+ key: 'in', ref: dateRef, type: 'date', value: due, 'aria-label': t('dueLabel'),
651
+ style: { position: 'absolute', inset: 0, opacity: 0, border: 0, padding: 0, width: '100%', height: '100%', cursor: 'pointer' },
652
+ onChange: (event) => setDate(event.target.value),
653
+ }),
654
+ ]),
655
+ // 时间:同样用原生 time 输入;只选时间时自动补今天
656
+ React.createElement('label', {
657
+ key: 'time',
658
+ className: `wt-chip${parsed && parsed.dueTime ? ' is-parsed' : ''}`,
659
+ title: t('dueTimeLabel'),
660
+ onClick: (event) => { event.preventDefault(); openPicker(timeRef); },
661
+ style: { position: 'relative', cursor: 'pointer', color: dueTime ? C.label : C.label3, borderColor: parsed && parsed.dueTime ? C.brand : undefined },
662
+ }, [
663
+ React.createElement(IconClock, { key: 'i' }),
664
+ React.createElement('span', { key: 'v' }, `${timeLabel}${parsed && parsed.dueTime ? ` · ${t('fromParsed')}` : ''}`),
665
+ React.createElement('input', {
666
+ key: 'in', ref: timeRef, type: 'time', value: dueTime, lang: 'zh-CN', 'aria-label': t('dueTimeLabel'),
667
+ style: { position: 'absolute', inset: 0, opacity: 0, border: 0, padding: 0, width: '100%', height: '100%', cursor: 'pointer' },
668
+ onChange: (event) => setTime(event.target.value),
669
+ }),
670
+ ]),
671
+ // 优先级:点开四个小旗
672
+ React.createElement('span', { key: 'prio', style: { position: 'relative' } }, [
673
+ React.createElement('button', {
674
+ key: 'btn', type: 'button', className: 'wt-chip', style: { color: prioColor, cursor: 'pointer', font: 'inherit', fontSize: 11.5 },
675
+ title: t('priorityLabel'), 'aria-expanded': flagOpen,
676
+ onClick: () => setFlagOpen((open) => !open),
677
+ }, [
678
+ React.createElement(IconFlag, { key: 'f', dashed: priority === 'none', filled: priority !== 'none' }),
679
+ React.createElement('span', { key: 'v' }, `${t('priorityLabel')} · ${prioLabel}`),
680
+ ]),
681
+ flagOpen ? React.createElement('div', {
682
+ key: 'pop',
683
+ style: {
684
+ position: 'absolute', top: '100%', left: 0, zIndex: 5, marginTop: 4, padding: 6, display: 'flex', gap: 4,
685
+ background: C.layer1, border: `1px solid ${C.border}`, borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,.12)',
686
+ },
687
+ }, flags.map((flag) => React.createElement('button', {
688
+ key: flag.value, type: 'button', className: 'wt-chip',
689
+ style: { color: flag.color, font: 'inherit', fontSize: 11.5, cursor: 'pointer', borderColor: priority === flag.value ? flag.color : undefined },
690
+ title: flag.label,
691
+ onClick: () => { setPriority(flag.value); setFlagOpen(false); },
692
+ }, [
693
+ React.createElement(IconFlag, { key: 'f', filled: priority === flag.value, dashed: flag.value === 'none' }),
694
+ React.createElement('span', { key: 'v' }, flag.label),
695
+ ]))) : null,
494
696
  ]),
495
697
  ]),
496
- React.createElement('button', {
497
- key: 'add',
498
- type: 'button',
499
- className: 'wt-btn wt-btn-primary',
500
- disabled: Boolean(busy),
501
- onClick: submit,
502
- }, t('add')),
503
698
  ]);
504
699
  }
505
700
 
@@ -531,6 +726,7 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
531
726
  due,
532
727
  priority: row.priority === 'high' || row.priority === 'low' ? row.priority : 'normal',
533
728
  project: typeof row.project === 'string' ? row.project : '',
729
+ dueTime: typeof row.dueTime === 'string' ? row.dueTime : '',
534
730
  notes: typeof row.notes === 'string' ? row.notes : '',
535
731
  });
536
732
  setNotesOpen(Boolean(row.notes));
@@ -549,7 +745,12 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
549
745
  const title = String(draft.title ?? '').trim();
550
746
  if (title !== (row.title ?? '')) patch.title = title;
551
747
  const nextDue = draft.due || '';
552
- if (nextDue !== due) patch.due = nextDue; // 空串 = 清空截止日期
748
+ if (nextDue !== due) {
749
+ patch.due = nextDue; // 空串 = 清空截止日期
750
+ if (!nextDue) patch.dueTime = 'none'; // 没有日期就没有时间
751
+ }
752
+ const nextDueTime = String(draft.dueTime ?? '').trim();
753
+ if (nextDue && nextDueTime !== (row.dueTime ?? '')) patch.dueTime = nextDueTime === '' ? 'none' : nextDueTime;
553
754
  const priority = draft.priority || 'normal';
554
755
  if (priority !== (row.priority ?? 'normal')) patch.priority = priority;
555
756
  const project = String(draft.project ?? '').trim();
@@ -601,6 +802,11 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
601
802
  'aria-label': t('dueLabel'),
602
803
  onChange: (event) => setDraft((prev) => ({ ...prev, due: event.target.value })),
603
804
  })),
805
+ field('tm', t('dueTimeLabel'), React.createElement('input', {
806
+ className: 'wt-input', style: inputStyle, type: 'time', value: draft.dueTime, disabled: !draft.due,
807
+ 'aria-label': t('dueTimeLabel'),
808
+ onChange: (event) => setDraft((prev) => ({ ...prev, dueTime: event.target.value })),
809
+ })),
604
810
  field('prio', t('priorityLabel'), React.createElement('select', {
605
811
  className: 'wt-input', style: inputStyle, value: draft.priority,
606
812
  'aria-label': t('priorityLabel'),
@@ -692,7 +898,16 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
692
898
  style: drag.dragging ? { opacity: 0.45 } : undefined,
693
899
  }
694
900
  : {};
695
- return React.createElement('div', { className: 'wt-row', ...dragAttrs }, [
901
+ return React.createElement('div', { className: 'wt-row', ...dragAttrs, style: row.priority === 'high' ? { borderLeft: `2px solid ${C.error}`, paddingLeft: 8 } : undefined }, [
902
+ /* 优先级呈现(设计稿 v5):高=红色左缘 + 实心红旗;中=琥珀;低=灰;无=什么都不显示 */
903
+ row.priority && row.priority !== 'none' ? React.createElement('span', {
904
+ key: 'prio-flag',
905
+ title: row.priority === 'high' ? t('prioHigh') : row.priority === 'normal' ? t('prioNormal') : t('prioLow'),
906
+ style: {
907
+ display: 'inline-flex', flex: '0 0 auto',
908
+ color: row.priority === 'high' ? C.error : row.priority === 'normal' ? C.warn : C.label3,
909
+ },
910
+ }, React.createElement(IconFlag, { filled: row.priority === 'high' })) : null,
696
911
  React.createElement('button', {
697
912
  key: 'check',
698
913
  type: 'button',
@@ -993,6 +1208,8 @@ function WorkTodoPanel(props) {
993
1208
  const stats = data?.stats ?? {};
994
1209
  const today = typeof data?.today === 'string' && data.today ? data.today : localToday();
995
1210
  const store = data?.store ?? null;
1211
+ const version = typeof data?.version === 'string' ? data.version : '';
1212
+ const update = data?.update ?? null;
996
1213
  const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
997
1214
  const overdueCount = num(stats.overdue);
998
1215
  // 视图分桶:按截止(原有 GROUPS)或按项目(宿主给的 projectGroups,附 未完成/总数/逾期)
@@ -1157,13 +1374,34 @@ function WorkTodoPanel(props) {
1157
1374
  React.createElement('div', {
1158
1375
  key: 'foot',
1159
1376
  style: { marginTop: 14, paddingTop: 8, borderTop: `1px solid ${C.border1}`, fontSize: 10.5, color: C.label3, lineHeight: 1.7 },
1160
- }, store && store.location
1161
- ? React.createElement('div', {
1162
- key: 'store',
1163
- title: `${t('storeAt')}:${store.location}`,
1164
- style: { wordBreak: 'break-all' },
1165
- }, `${t('storeAt')} · ${store.kind === 'domain' ? t('storeDomain') : t('storeFile')} · ${store.location}`)
1166
- : null),
1377
+ }, [
1378
+ /* 角落信息:装了哪一版 + 有没有新版(一眼可见,不占主区) */
1379
+ React.createElement('div', {
1380
+ key: 'ver',
1381
+ style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 },
1382
+ }, [
1383
+ React.createElement('span', {
1384
+ key: 'v',
1385
+ title: t('versionHint'),
1386
+ style: { color: C.label3 },
1387
+ }, `${t('versionLabel')} ${version ? `v${version}` : '—'}`),
1388
+ update?.hasUpdate
1389
+ ? React.createElement('span', {
1390
+ key: 'u',
1391
+ className: 'wt-chip',
1392
+ style: { fontSize: 10, color: C.ok, borderColor: tint(C.ok, 50), background: tint(C.ok, 12) },
1393
+ title: `${t('updateHint')}:dsh plugin --profile desktop add dsh-worktodo@${update.latest}`,
1394
+ }, `${t('updateAvailable')} v${update.latest}`)
1395
+ : null,
1396
+ ]),
1397
+ store && store.location
1398
+ ? React.createElement('div', {
1399
+ key: 'store',
1400
+ title: `${t('storeAt')}:${store.location}`,
1401
+ style: { wordBreak: 'break-all' },
1402
+ }, `${t('storeAt')} · ${store.kind === 'domain' ? t('storeDomain') : t('storeFile')} · ${store.location}`)
1403
+ : null,
1404
+ ]),
1167
1405
  ]);
1168
1406
  }
1169
1407