nx-kv 0.12.0 → 0.13.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +117 -1
  2. package/package.json +2 -1
  3. package/src/modules/auth/index.js +2 -1
  4. package/src/modules/localtask/index.js +23 -0
  5. package/src/modules/localtask/service.js +55 -0
  6. package/src/modules/localtask/view-overview.jsx +313 -41
  7. package/src/modules/repos/boards.js +123 -9
  8. package/src/modules/repos/group-view.jsx +127 -0
  9. package/src/modules/repos/index.js +40 -0
  10. package/src/modules/repos/service.js +121 -2
  11. package/src/modules/repos/view.jsx +102 -7
  12. package/src/modules/templates/index.js +97 -0
  13. package/src/modules/templates/service.js +182 -0
  14. package/src/modules/todo/view.jsx +71 -3
  15. package/src/runtime/registry.js +2 -1
  16. package/src/web/frontend/App.jsx +325 -143
  17. package/src/web/frontend/app-context.jsx +13 -0
  18. package/src/web/frontend/components/icons.jsx +9 -2
  19. package/src/web/frontend/components/layout/tabs.jsx +2 -0
  20. package/src/web/frontend/components/sidebar/drag.js +29 -40
  21. package/src/web/frontend/components/sidebar/index.jsx +88 -68
  22. package/src/web/frontend/components/sidebar/model.js +138 -64
  23. package/src/web/frontend/components/sidebar/panel.jsx +98 -0
  24. package/src/web/frontend/components/sidebar/queue-nav.js +40 -0
  25. package/src/web/frontend/components/sidebar/row-menu.jsx +72 -36
  26. package/src/web/frontend/components/sidebar/task-children.jsx +24 -10
  27. package/src/web/frontend/modules/md-workspace/doc-store.js +65 -0
  28. package/src/web/frontend/modules/md-workspace/landing.jsx +83 -18
  29. package/src/web/frontend/modules/md-workspace/md-edit-ops.js +340 -0
  30. package/src/web/frontend/modules/md-workspace/md-history.js +78 -0
  31. package/src/web/frontend/modules/md-workspace/md-panes.jsx +1109 -68
  32. package/src/web/frontend/modules/md-workspace/md-parse.js +38 -8
  33. package/src/web/frontend/modules/md-workspace/page-panel.jsx +406 -0
  34. package/src/web/frontend/modules/md-workspace/progress-panel.jsx +139 -55
  35. package/src/web/frontend/modules/md-workspace/sel-menu.jsx +55 -8
  36. package/src/web/frontend/modules/md-workspace/shortcut-sheet.jsx +90 -0
  37. package/src/web/frontend/modules/md-workspace/style.css +433 -33
  38. package/src/web/frontend/modules/md-workspace/template-editor.jsx +120 -0
  39. package/src/web/frontend/modules/md-workspace/template-manager.jsx +222 -0
  40. package/src/web/frontend/modules/md-workspace/tool-bar.jsx +218 -19
  41. package/src/web/frontend/modules/md-workspace/use-templates.js +74 -0
  42. package/src/web/frontend/modules/md-workspace/view.jsx +67 -369
  43. package/src/web/frontend/panels.jsx +168 -0
  44. package/src/web/frontend/prefs.js +29 -9
  45. package/src/web/frontend/registry.js +2 -1
  46. package/src/web/frontend/style-v2.css +217 -14
  47. package/src/web/frontend/style.css +60 -0
  48. package/src/web/frontend/workspace.js +353 -0
  49. package/src/web/public/assets/{CliHints-Br4yygbI.js → CliHints-BNQsBqZi.js} +1 -1
  50. package/src/web/public/assets/doc-store-CA9Jn7_9.js +1 -0
  51. package/src/web/public/assets/group-view-CJVzYtci.js +1 -0
  52. package/src/web/public/assets/index-H_va8kI7.css +1 -0
  53. package/src/web/public/assets/index-clupHox-.js +69 -0
  54. package/src/web/public/assets/jsx-runtime-Dk72oS4N.js +1 -0
  55. package/src/web/public/assets/page-panel-wVjYxy-c.js +5 -0
  56. package/src/web/public/assets/progress-panel-BWuNb1HU.js +3 -0
  57. package/src/web/public/assets/view-BaFnDBlk.js +19 -0
  58. package/src/web/public/assets/view-CyStUXmO.js +2 -0
  59. package/src/web/public/assets/view-D-Ve3jn9.js +3 -0
  60. package/src/web/public/assets/view-overview-B6pbN-hr.js +1 -0
  61. package/src/web/public/index.html +3 -3
  62. package/src/modules/auth/view.jsx +0 -78
  63. package/src/web/frontend/modules/md-workspace/tabs-store.js +0 -130
  64. package/src/web/public/assets/index-D4KgmRkn.js +0 -11
  65. package/src/web/public/assets/index-D4kU6s0M.css +0 -1
  66. package/src/web/public/assets/jsx-runtime-DLNB9Qsn.js +0 -1
  67. package/src/web/public/assets/view-B4fbTb4n.js +0 -3
  68. package/src/web/public/assets/view-BcGdYDG2.js +0 -21
  69. package/src/web/public/assets/view-BjbVZ-n0.js +0 -19
  70. package/src/web/public/assets/view-DvCKCWZ8.js +0 -1
  71. package/src/web/public/assets/view-DxSQKWVx.css +0 -1
  72. package/src/web/public/assets/view-overview-tr3l0EUh.js +0 -1
package/CHANGELOG.md CHANGED
@@ -2,10 +2,107 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
- ## [0.12.0] - 2026-10-30 仓库身份改用全局唯一 key + 导入导出重做
5
+ ## [0.13.0] - 2026-10-31 任务模板可配置 + 完成任务可删除 + 无队列仓库可从侧边栏新建任务 + 仓库前缀分组
6
6
 
7
7
  ### Added
8
8
 
9
+ - **点项目组/项目目录 = 看表格**:
10
+ - 新增**项目组详情**(`#/group/<看板 id>`):组里的**子目录**以表格呈现 ——
11
+ 目录 / 路径 / 说明 / 待领取 / 归属(手动 vs 按前缀)。点组行进这里,点表行进该目录。
12
+ - 目录工作台的落地页改成**子任务表**(表格):编号 / 任务 / 状态 / 创建 / 领取 /
13
+ 删除。列表把「什么时候建、什么时候被领走」压成一行就丢了,表格才看得到。
14
+ - 侧栏点击语义分开:**点行 = 去看内容**(组 → 组表,目录 → 子任务表),
15
+ **点 chevron = 在树里展开/收起**。以前两者抢同一个点击,展开就没法兼得。
16
+ - **仓库按前缀分组**:「仓库名以 nx 开头 → 归成一类」这类规则现在有了。
17
+ - 规则只改**成员关系**,看板仍是唯一分组载体 —— 侧边栏分组、仓库页看板筛选、
18
+ 成员管理全都自动跟着生效,不用各改一遍。
19
+ - 规则存服务端(仓库 store 的 `prefixGroups`),换浏览器/换机一致;
20
+ 规则同时记看板 id 与 key(改名不失效、导入换机有兜底)。
21
+ - CLI:`board prefix list` / `board prefix add <前缀> [--board <看板>]` /
22
+ `board prefix remove <前缀>`;HTTP 同构:
23
+ `GET/POST /api/boards/prefix-groups`、`DELETE /api/boards/prefix-groups/:prefix`。
24
+ 目标看板留空 = 按前缀名**自动建同名看板**(如 `board prefix add nx`)。
25
+ - 面板:仓库管理页「看板」区新增「按前缀分组」区块(规则 chip + 前缀/看板输入 + 添加)。
26
+ - 删规则时,**规则建的空看板**一并清掉;看板里有手动成员则只删规则、不动看板。
27
+ - **侧边栏分组读成组**:看板的成员行原本混在全局名称序里(组标题与成员不相邻,
28
+ 「nx 一类」看着只是一堆缩进行)。现在成员紧跟在自己的看板行下面,未归组的仓库
29
+ 排在所有组之后。
30
+ - **任务模板(`template` 命令族 + 面板管理)**:MD 工作台「新建页」的起始正文不再写死。
31
+ - 存 `~/.nx-kv/templates.json`(`NX_KV_TEMPLATES_STORE` 可覆盖);内置一条默认模板
32
+ (不可删),删掉自定义默认后默认回落到内置。
33
+ - 面板:管理入口在**侧边栏设置**(齿轮 → 「任务模板…」弹窗:新增 / 编辑 / 删除 /
34
+ 设为默认)。弹窗头部写明新建页默认用哪个模板;每行给正文前几行的等宽预览;
35
+ 正文用 **MD 编辑器**(源码 + 实时预览 + 格式工具条 B/I/代码/链接/列表/待办/引用/
36
+ H2/H3,Ctrl/⌘+B/I/E/K,视图可切并排/源码/预览)——模板本身就是 Markdown,
37
+ 配置它不该是一个裸 textarea;编辑是行内表单(Ctrl/⌘+Enter 保存);删除带确认。
38
+ 落地页「模板 ▾」菜单按模板直接开新页(✓ 标默认)。「新建页」「新任务」一律取
39
+ 默认模板正文,API 不可达时退回内置骨架(与旧版行为一致)。设置与工作台共享
40
+ 同一份模板数据,改完即时生效。
41
+ - CLI:`template list` / `template add <name> [--body <md> | --body-file <文件> | --body -]`
42
+ `[--setDefault]` / `template update <id> [--name] [--body|--body-file]` /
43
+ `template remove <id>` / `template default <id>`;HTTP 同构:
44
+ `GET/POST /api/templates`、`PATCH /api/templates/:id`、
45
+ `DELETE /api/templates/:id`、`PATCH /api/templates/default`。
46
+ 多行 Markdown 正文在 cmd.exe 里塞不进参数,所以给了 `--body-file`(读文件)与
47
+ `--body -`(读标准输入)——这两条**只在 CLI 生效**,HTTP 不暴露读本机文件。
48
+ - **设置齿轮图标画错**:原来的 `gear` 是「圆心 + 八向光线」的日芒/亮度形状,不像设置。
49
+ 重画为标准齿轮(中心圆 + 齿圈)。
50
+ - **旧版 serve 进程配新版面板资产时,模板弹窗只说「加载失败」**:路由在进程启动时加载、
51
+ 静态资产从磁盘现读——跑着旧进程时新 UI 会拿到 `/api/templates` 404。现在识别出
52
+ 「接口不存在」这种版本偏差,弹窗明说「本机面板服务较旧,重启面板后重试」并隐藏
53
+ 必然失败的表单,只留「重试」;普通瞬时失败同样给「重试」。
54
+
55
+ ### Fixed
56
+
57
+ - **绑定的仓库没有任务队列时,侧边栏展不开也建不了任务**:仓库行的展开键在无队列时
58
+ 是空串,渲染判断永远为 false——「+ 新建页」子行根本不出现;直接跳工作台又会因
59
+ 找不到队列主题落「目录不存在」。现在点无队列仓库能正常展开,点「新建页」/行菜单
60
+ 「打开详情」/收起态 rail 图标会先自动登记该目录为任务队列(幂等、防 prune),再进
61
+ 工作台。
62
+ - **本地任务完成后没有任何删除入口**:完成(consumed)的任务此前只能留存在列表里。
63
+ 现在两处都可删:落地页列表行悬停出「✕」;打开的页面在工具栏「更多」里有「删除」。
64
+ 删除是**软删除**(`deletedAt` 标记,CLI `localtask task restore` 可恢复),已打开的
65
+ 页签会随手关闭。
66
+
67
+ ## [0.12.1] - 2026-10-30 key 归一化修复 + remote 对齐反馈
68
+
69
+ ### Fixed
70
+
71
+ - **keyFromRemote 在 Linux 上多一个前导斜杠**:同一地址在「派生」(keyFromRemote)与
72
+ 「归一」(normalizeKey)两条路上算出的 key 必须一致。Linux 的 `file:///tmp/x` 剥协议后
73
+ 带前导斜杠,于是「派生」得 `/tmp/x`、「归一」得 `tmp/x` —— 同一份数据算出两个 key,
74
+ 导入时参考关系直接连不上(「参考仓库不存在」)。Windows 上是盘符所以走不到这个分支,
75
+ 只有 CI 红。keyFromRemote 的结果现在就规范化(它是「key 的来源」,不该产出非规范形态)。
76
+ - **remote 对齐弹窗的「检查」点了像没反应**:git ls-remote 要几秒,期间界面毫无变化;
77
+ 出错又被 toast 吞掉(一闪而过)。改成点击即显示「检查中…」,错误原因直接写进弹窗
78
+ (错误信息不该只活 3 秒)。
79
+
80
+ ### Changed
81
+
82
+ - 并入三个工作树的成果:md-desc(关系改 key + 导入占位 + remote 对齐)、
83
+ sidebar-repo-boards(侧边栏改用仓库登记 + 看板)、fix-checkbox-width
84
+ (勾选框被 width:100% 撑满 → 选项列表看着一片空白)。
85
+
86
+ ## [0.12.0] - 2026-10-30 仓库身份改用全局唯一 key + 导入导出重做
87
+
88
+ ### Changed
89
+
90
+ - **侧边栏不再自建一套「目录 + 分组」,改为直接用仓库登记与看板**。
91
+ 以前侧边栏的「目录」是本地任务队列按 cwd 自动生成的主题,「分组」存在浏览器
92
+ localStorage 里(`prefs.groups` / `prefs.dirGroups`)——后者与仓库页的看板是
93
+ **两套互不相干的数据**:换浏览器分组就没了,改看板侧边栏也不知道。
94
+ 现在:
95
+ - 目录行 = **仓库登记**里的开发仓库(带全局唯一 key、描述、路径);
96
+ - 分组行 = **看板**(展开看成员),归属关系就是看板的成员表;
97
+ - 侧边栏只保留纯 UI 偏好:排序、隐藏、展开态、收起态(`prefs` 里的
98
+ `groups` / `dirGroups` 已废弃并在读取时从 localStorage 抹掉);
99
+ - 行菜单改操作真实数据:「归入/移出看板」走 `board assign`,
100
+ 「+」菜单变成「登记仓库 / 新建看板」(原来建的是任务队列主题,不是仓库登记);
101
+ - 拖拽只改顺序(显示态),归属一律走行菜单——拖拽不再写本地分组。
102
+ - 仓库模块:看板成员从随机 id(`repoIds`)迁到 **key**(`repoKeys`),读时自动
103
+ 迁移并落盘;绑定目标同样记 key;`keyFromRemote` 剥 `file://` URL 的盘符冒号
104
+ (否则 `normalizeKey` 会把 `:` 压成 `-`,导致「登记的是 A、成员表里是 B」)。
105
+
9
106
  - **内部关系全面改用 key(不再是随机 id)+ 导入分组的「本地未拥有」占位**:
10
107
  - **关系一律用 key**:看板成员存 `repoKeys`(不是 `repoIds`)、组内描述按 key、
11
108
  绑定关系(linked,ref 侧与 dev 侧双向记录)也按 key。`repo.id` / `board.id`
@@ -22,6 +119,25 @@
22
119
  `repo remote check`(`git ls-remote` 检查远端是否存在,**存在 / 不存在 /
23
120
  不可达**三种结论分清)。面板上每行有「remote」按钮,弹窗里下拉切换 + 存在性徽标。
24
121
 
122
+ - **MD 工作台编辑器专业对齐(10 轮 UI/UX 循环,纪要见 `plan/LOOP-MD-EDITOR.md`)**:
123
+ - **标题不再截断**:live 与源码两态的标题改为自动增高 textarea,长标题按宽度软换行;
124
+ Enter 不产生换行、直接把焦点交给正文(live 进首块 / 源码回正文 0 位)
125
+ - **工具栏分层**:模式 | 格式 | 插入三簇 hairline 分组,格式组去灰底容器(hover 才显底),
126
+ 次级动作去描边转文本态,视觉重量留给主按钮
127
+ - **自适应改容器查询**:`.mdw-main` 声明 container,工具栏/纸面按「编辑列」实际宽度三级折叠
128
+ (1280 收转变组 / 960 收进溢出菜单 / 640 纸面紧凑)——修掉右侧进程 rail 一开就静默裁按钮的缺陷
129
+ - **溢出菜单 portal 到 body**:修 `···`/`更多` 菜单被工具栏 `overflow:hidden` 整条裁掉的缺陷
130
+ - **阅读态补题头**:三态标题呈现统一,题头与正文同轴同宽
131
+ - **空文档幽灵引导**:空文档渲染 Notion 式引导条,点击直接聚焦首块
132
+ - **撤销/重做**:新增文档级历史栈——受控 textarea 的程序化改写会冲掉浏览器原生 undo 栈,
133
+ 原先「Ctrl+B 加粗 → Ctrl+Z」毫无反应;现在格式操作、输入段均可撤销/重做,
134
+ 且撤销后光标回到改动处(源码精确,live 跨块自动跳转并接管焦点)
135
+ - **Ctrl/⌘S 保存 + 页签未保存圆点**:改动即在页签挂一颗点,保存/提交后摘除
136
+ - **快捷键速查面板**:Ctrl/⌘ / 或工具栏问号唤起,15 条键位分三组
137
+ - **实时预览块级查找**:Ctrl/⌘F 在预览态可用,n/m 计数 + 命中块高亮 + 滚动定位
138
+ - **无障碍**:编辑区 aria-label 补齐、状态栏小字对比度提到 WCAG AA
139
+
140
+
25
141
  - **仓库获得全局唯一 key,导入导出改以 key 为身份(v3)+ 全屏导入导出面板**:
26
142
  - **key**:归一化远程地址派生(`git@c:o/r.git`、`https://c/o/r`、
27
143
  `ssh://git@ssh.github.com:443/o/r.git`、带端口、带 `.git` 都是同一个
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,6 +52,7 @@
52
52
  "vite": "^8.3.0"
53
53
  },
54
54
  "dependencies": {
55
+ "flexlayout-react": "0.11.1",
55
56
  "react": "^19.3.0",
56
57
  "react-dom": "^19.3.0"
57
58
  }
@@ -32,7 +32,8 @@ export default {
32
32
  id: 'auth',
33
33
  title: '认证(登录态)',
34
34
  order: 10,
35
- view: () => import('./view.jsx'),
35
+ // 登录 UI 已合并进云端页(frontend todo view),此处只保留 CLI/HTTP action;
36
+ // 独立面板视图已下线(2026-10-30)。
36
37
 
37
38
  actions: [
38
39
  {
@@ -216,6 +216,29 @@ export default {
216
216
  ? `已彻底删除主题「${d.name}」#${d.removed.id}: ${d.removed.text}(不可恢复)`
217
217
  : `已删除主题「${d.name}」#${d.removed.id}: ${d.removed.text}(可用 localtask task restore 恢复)`,
218
218
  },
219
+ {
220
+ id: 'localtask.removeMany',
221
+ cli: ['localtask', 'remove-many'],
222
+ http: ['DELETE', '/api/localtasks/items'],
223
+ summary:
224
+ '批量删除任务(按 --ids,定位到 --cwd 主题或 --key):一次落盘,逐条软删除(可 restore 恢复);缺失的编号不阻断其余删除',
225
+ flags: {
226
+ ids: { type: 'array', required: true, hint: '任务编号,逗号分隔或给多次' },
227
+ cwd: CWD,
228
+ key: KEY,
229
+ purge: { type: 'boolean', hint: '彻底删除(不可恢复);不加就是软删除' },
230
+ },
231
+ run: (ctx) => service.removeTasks({ ids: ctx.ids, cwd: ctx.cwd, key: ctx.key, purge: ctx.purge }),
232
+ render: (d) => {
233
+ const done = d.removed.map((x) => `#${x.id}`).join(' ');
234
+ const head = d.count
235
+ ? `${d.purged ? '已彻底删除' : '已删除'}主题「${d.name}」${d.count} 条(${done})`
236
+ : `主题「${d.name}」没有可删的任务`;
237
+ const tail = d.missing.length ? `;未找到 #${d.missing.join(' #')}` : '';
238
+ const how = d.count && !d.purged ? ';可用 localtask task restore 恢复' : '';
239
+ return head + tail + how;
240
+ },
241
+ },
219
242
  {
220
243
  id: 'localtask.task.restore',
221
244
  cli: ['localtask', 'task', 'restore'],
@@ -931,6 +931,61 @@ export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false }
931
931
  return { status: 'ok', purged: false, key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(task) };
932
932
  }
933
933
 
934
+ /**
935
+ * 批量删除(面板「删除选中 n」用):一次 loadStore / saveStore 处理多条。
936
+ *
937
+ * 单条语义与 removeTask 完全一致(软删除打 deletedAt,purge 彻底摘除),
938
+ * 但**不因个别 id 不存在而整体失败**:缺失的 id 收进 missing 让面板照实提示。
939
+ * 为什么不做成「循环调 removeTask」:那会变成 N 次读盘 + N 次整份写盘,
940
+ * 中间任何一次失败都会留下「删了一半、不知道删到哪」的状态。
941
+ *
942
+ * 重复删除同一条是幂等的(deletedAt 已有值就不再改写时间戳)。
943
+ */
944
+ export async function removeTasks({ cwd: rawCwd, key: rawKey, ids, purge = false } = {}) {
945
+ const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
946
+ const { cwd } = normalizeCwd(rawCwd);
947
+ const wants = (Array.isArray(ids) ? ids : [ids])
948
+ .map((x) => Number(String(x ?? '').trim()))
949
+ .filter((n) => Number.isFinite(n) && n > 0);
950
+ if (!wants.length) throw badInput('缺少参数 --ids <任务编号,逗号分隔或给多次>');
951
+
952
+ const store = await loadStore();
953
+ const t = store.topics[key];
954
+ const removed = [];
955
+ const missing = [];
956
+ for (const want of wants) {
957
+ const index = t ? t.tasks.findIndex((x) => Number(x.id) === want) : -1;
958
+ if (index < 0) {
959
+ missing.push(want);
960
+ continue;
961
+ }
962
+ const task = t.tasks[index];
963
+ if (purge) {
964
+ // 彻底清除:行真的摘出去(不可恢复)。与 removeTask 同一套词汇。
965
+ t.tasks.splice(index, 1);
966
+ removed.push({ ...normalizeTask(task), purged: true });
967
+ continue;
968
+ }
969
+ if (!task.deletedAt) task.deletedAt = stampNow();
970
+ task.trashIndex = index; // 恢复时放回原位(与 removeTask 一致)
971
+ removed.push({ ...normalizeTask(task), purged: false });
972
+ }
973
+ if (removed.length) {
974
+ t.updatedAt = stampNow();
975
+ await saveStore(store);
976
+ }
977
+ return {
978
+ status: 'ok',
979
+ key,
980
+ cwd: t ? t.cwd || cwd : cwd,
981
+ name: t ? t.name : key,
982
+ purged: !!purge,
983
+ removed,
984
+ missing,
985
+ count: removed.length,
986
+ };
987
+ }
988
+
934
989
  /**
935
990
  * 恢复一条被软删除的任务(撤销删除)。
936
991
  * 与 removeTask 互为逆操作:任务仍在队列里,只是带着 deletedAt 标记。
@@ -1,15 +1,153 @@
1
- // 全部目录总览(#/overview):目录概览条(chip:名称+待领取数+等待标记)
2
- // + 按目录分组的待领取列表(v1 跨目录视角的接棒者)。
3
- import { useMemo } from 'react';
1
+ // 全部目录总览(#/overview):筛选条(chip:目录名+待领取数+等待标记,**只筛选不跳转**)
2
+ // + 按目录分组的任务列表(待领取 + 可折叠的已领取历史)。
3
+ //
4
+ // 交互约定(2026-10-31 三次修订):
5
+ // 1. **顶部 chip 只做筛选**:点 = 选中/取消该目录(多选),「全部」清空筛选。
6
+ // 不再点 chip 跳目录——进目录走分区标题链接,进任务详情点任务行。
7
+ // 2. **每行任务带状态 tag**(待领取 / 已领取 / 已迁移 / 已删除,口径与
8
+ // splitQueueTasks / taskToProgress 一致);已领取历史默认折叠,点开可见。
9
+ // 3. **批量删除的落点在底部操作条**:勾选任意行后出现(position:sticky),
10
+ // 跨目录一次删完(按目录分组逐个调 DELETE /api/localtasks/items);
11
+ // 仍是软删除(CLI localtask task restore 可恢复)。分区头只留「全选」。
12
+ // 4. 任务行可点:直接进该任务的工作台详情(#/dir/<key>?task=<id>);
13
+ // 已领取行同样可点(详情页以「已回填」进度卡呈现)。
14
+ import { useMemo, useState } from 'react';
15
+ import { api } from '../../web/frontend/api/client.js';
4
16
  import { Icon } from '../../web/frontend/components/icons.jsx';
5
- import { splitQueueTasks } from './board.js';
17
+ import { useToast } from '../../web/frontend/components/ui.jsx';
18
+ import { titleFromMd } from '../../web/frontend/modules/md-workspace/md-parse.js';
19
+ import { GLOBAL_KEY, splitQueueTasks } from './board.js';
20
+
21
+ const NO_IDS = new Set();
22
+
23
+ /** 任务状态 tag:与 board.splitQueueTasks、md-workspace taskToProgress 同一口径。 */
24
+ function statusTag(t) {
25
+ if (t.deletedAt) return { label: '已删除', cls: 't-gone' };
26
+ if (t.migratedTo) return { label: '已迁移', cls: 't-gone' };
27
+ if (t.status === 'consumed') return { label: '已领取', cls: 't-done' };
28
+ return { label: '待领取', cls: 't-pend' };
29
+ }
30
+
31
+ export default function Overview({ rows, onNavigate, refreshLocal }) {
32
+ const toast = useToast();
33
+ // 勾选状态:Map<分区 key, Set<任务编号>>。行会随轮询刷新而消失,所以计数与删除
34
+ // 一律与「当前队列里仍存在且未删除」取交集,不额外做清理 effect。
35
+ const [sel, setSel] = useState(() => new Map());
36
+ // 筛选:选中的分区 key 集合;空集 = 全部。chip 只改这里,不做导航。
37
+ const [filter, setFilter] = useState(() => new Set());
38
+ // 「已领取」历史折叠:展开的分区 key 集合(默认全收起)。
39
+ const [doneOpen, setDoneOpen] = useState(() => new Set());
6
40
 
7
- export default function Overview({ rows, onNavigate }) {
8
41
  const dirs = useMemo(() => rows.filter((r) => !r.isGlobal), [rows]);
9
42
  const globalRow = rows.find((r) => r.isGlobal);
10
43
  const totalPending = dirs.reduce((n, r) => n + (r.pending || 0), 0) + (globalRow?.pending || 0);
11
44
  const waiting = rows.filter((r) => r.waiting);
12
45
 
46
+ const secKeyOf = (r) => (r.isGlobal ? GLOBAL_KEY : r.key);
47
+
48
+ // 跨目录的当前选中总数(只数仍存在且未删除的)
49
+ const selCount = useMemo(() => {
50
+ let n = 0;
51
+ for (const r of rows) {
52
+ const cur = sel.get(secKeyOf(r));
53
+ if (!cur || !cur.size) continue;
54
+ for (const t of r.tasks || []) if (cur.has(Number(t.id)) && !t.deletedAt) n += 1;
55
+ }
56
+ return n;
57
+ }, [rows, sel]);
58
+
59
+ const idsOf = (secKey) => sel.get(secKey) || NO_IDS;
60
+ const pickedOf = (secKey, pending) => {
61
+ const cur = idsOf(secKey);
62
+ return cur.size ? pending.map((t) => Number(t.id)).filter((id) => cur.has(id)) : [];
63
+ };
64
+
65
+ const toggleOne = (secKey, id) => {
66
+ setSel((prev) => {
67
+ const next = new Map(prev);
68
+ const cur = new Set(next.get(secKey) || []);
69
+ if (cur.has(id)) cur.delete(id);
70
+ else cur.add(id);
71
+ if (cur.size) next.set(secKey, cur);
72
+ else next.delete(secKey);
73
+ return next;
74
+ });
75
+ };
76
+
77
+ const toggleAll = (secKey, pending, clear) => {
78
+ setSel((prev) => {
79
+ const next = new Map(prev);
80
+ if (clear) next.delete(secKey);
81
+ else next.set(secKey, new Set(pending.map((t) => Number(t.id))));
82
+ return next;
83
+ });
84
+ };
85
+
86
+ const toggleFilter = (key) => {
87
+ setFilter((prev) => {
88
+ const next = new Set(prev);
89
+ if (next.has(key)) next.delete(key);
90
+ else next.add(key);
91
+ return next;
92
+ });
93
+ };
94
+
95
+ const toggleDone = (secKey) => {
96
+ setDoneOpen((prev) => {
97
+ const next = new Set(prev);
98
+ if (next.has(secKey)) next.delete(secKey);
99
+ else next.add(secKey);
100
+ return next;
101
+ });
102
+ };
103
+
104
+ /** 底部操作条的删除:把选中项按目录分组,逐组调批量删除接口。 */
105
+ const dropAll = async () => {
106
+ const groups = [];
107
+ for (const r of rows) {
108
+ const key = secKeyOf(r);
109
+ const cur = sel.get(key);
110
+ if (!cur || !cur.size) continue;
111
+ const ids = (r.tasks || [])
112
+ .filter((t) => cur.has(Number(t.id)) && !t.deletedAt)
113
+ .map((t) => Number(t.id));
114
+ if (ids.length) groups.push({ key, name: r.name || key, ids });
115
+ }
116
+ const total = groups.reduce((n, g) => n + g.ids.length, 0);
117
+ if (!total) return;
118
+ const scope = groups.length > 1 ? `(跨 ${groups.length} 个目录)` : '';
119
+ if (!confirm(`删除 ${total} 条任务${scope}?\n软删除:CLI localtask task restore 可恢复。`)) return;
120
+ let n = 0;
121
+ let miss = 0;
122
+ const errs = [];
123
+ for (const g of groups) {
124
+ try {
125
+ const r = await api('/api/localtasks/items', { method: 'DELETE', body: { key: g.key, ids: g.ids } });
126
+ n += Number(r?.count ?? 0);
127
+ miss += (r?.missing || []).length;
128
+ } catch (e) {
129
+ const msg = String(e.message || e);
130
+ // 路由在 serve 进程启动时加载:跑着旧进程时静态资产是新的、接口是旧的
131
+ if (/接口不存在/.test(msg)) {
132
+ toast('本机面板服务较旧,重启面板后再试(批量删除接口未上线)');
133
+ return;
134
+ }
135
+ errs.push(`${g.name}: ${msg}`);
136
+ }
137
+ }
138
+ if (errs.length) {
139
+ toast(`已删除 ${n} 条;${errs.length} 个目录失败:${errs[0]}${errs.length > 1 ? ` 等 ${errs.length} 项` : ''}`);
140
+ } else {
141
+ toast(`已删除 ${n} 条${miss ? `(${miss} 条未找到,可能已被删除)` : ''}`);
142
+ }
143
+ setSel(new Map());
144
+ refreshLocal?.();
145
+ };
146
+
147
+ const openTask = (base, id) => onNavigate(`${base}?task=${encodeURIComponent(id)}`);
148
+ const dirBase = (key) => '#/dir/' + encodeURIComponent(key);
149
+ const shown = (key) => !filter.size || filter.has(key);
150
+
13
151
  return (
14
152
  <div className="ov-wrap">
15
153
  <div className="ov-head">
@@ -21,8 +159,22 @@ export default function Overview({ rows, onNavigate }) {
21
159
  </div>
22
160
 
23
161
  <div className="ov-chips">
162
+ <span className="ov-filter-label">筛选</span>
163
+ <button
164
+ type="button"
165
+ className={'ov-chip' + (!filter.size ? ' on' : '')}
166
+ onClick={() => setFilter(new Set())}
167
+ title="显示全部目录"
168
+ >
169
+ 全部
170
+ </button>
24
171
  {globalRow && (
25
- <button className="ov-chip" onClick={() => onNavigate('#/global')}>
172
+ <button
173
+ type="button"
174
+ className={'ov-chip' + (filter.has(GLOBAL_KEY) ? ' on' : '') + (globalRow.waiting ? ' wait' : '')}
175
+ onClick={() => toggleFilter(GLOBAL_KEY)}
176
+ title="筛选:只看全局任务"
177
+ >
26
178
  <Icon name="globe" size={13} /> 全局任务
27
179
  <span className="c">{globalRow.pending || 0}</span>
28
180
  {globalRow.waiting && <span className="wdot" />}
@@ -30,10 +182,11 @@ export default function Overview({ rows, onNavigate }) {
30
182
  )}
31
183
  {dirs.map((d) => (
32
184
  <button
185
+ type="button"
33
186
  key={d.key}
34
- className={'ov-chip' + (d.waiting ? ' wait' : '')}
35
- onClick={() => onNavigate('#/dir/' + encodeURIComponent(d.key))}
36
- title={d.cwd}
187
+ className={'ov-chip' + (filter.has(d.key) ? ' on' : '') + (d.waiting ? ' wait' : '')}
188
+ onClick={() => toggleFilter(d.key)}
189
+ title={`筛选:${filter.has(d.key) ? '取消' : '只看'}「${d.name}」`}
37
190
  >
38
191
  <Icon name="folder" size={13} /> {d.name}
39
192
  <span className="c">{d.pending || 0}</span>
@@ -45,52 +198,171 @@ export default function Overview({ rows, onNavigate }) {
45
198
  )}
46
199
  </div>
47
200
 
48
- {globalRow && (globalRow.pending || 0) > 0 && (
49
- <section className="ov-sec">
50
- <div className="ov-sec-h">
51
- <Icon name="globe" size={13} /> 全局任务
52
- <span className="c">{globalRow.pending} 待领取</span>
53
- <span className="grow" />
54
- <button className="mini" onClick={() => onNavigate('#/global')}>打开</button>
55
- </div>
56
- {(globalRow.tasks || []).slice(0, 0).map(() => null)}
57
- <PendingList tasks={globalRow.tasks} />
58
- </section>
201
+ {globalRow && shown(GLOBAL_KEY) && ((globalRow.pending || 0) > 0 || globalRow.waiting) && (
202
+ <Sec
203
+ secKey={GLOBAL_KEY}
204
+ icon="globe"
205
+ title="全局任务"
206
+ cwd=""
207
+ tasks={globalRow.tasks}
208
+ waiting={!!globalRow.waiting}
209
+ selected={pickedOf(GLOBAL_KEY, splitQueueTasks(globalRow.tasks || []).pending)}
210
+ doneOpen={doneOpen.has(GLOBAL_KEY)}
211
+ onToggle={(id) => toggleOne(GLOBAL_KEY, id)}
212
+ onToggleAll={(clear, pending) => toggleAll(GLOBAL_KEY, pending, clear)}
213
+ onToggleDone={() => toggleDone(GLOBAL_KEY)}
214
+ onOpenDir={() => onNavigate('#/global')}
215
+ onOpenTask={(id) => openTask('#/global', id)}
216
+ />
59
217
  )}
60
218
 
61
219
  {dirs.map((d) => {
62
220
  const split = splitQueueTasks(d.tasks || []);
221
+ if (!shown(d.key)) return null;
63
222
  if (!split.pending.length && !d.waiting) return null;
64
223
  return (
65
- <section className="ov-sec" key={d.key}>
66
- <div className="ov-sec-h">
67
- <Icon name="folder" size={13} /> {d.name}
68
- <span className="c mono">{d.cwd}</span>
69
- <span className="c">{split.pending.length} 待领取</span>
70
- {d.waiting && <span className="badge-wait"><span className="pulse" /> 等待回填</span>}
71
- <span className="grow" />
72
- <button className="mini" onClick={() => onNavigate('#/dir/' + encodeURIComponent(d.key))}>打开</button>
73
- </div>
74
- <PendingList tasks={d.tasks} />
75
- </section>
224
+ <Sec
225
+ key={d.key}
226
+ secKey={d.key}
227
+ icon="folder"
228
+ title={d.name}
229
+ cwd={d.cwd}
230
+ tasks={d.tasks}
231
+ waiting={!!d.waiting}
232
+ selected={pickedOf(d.key, split.pending)}
233
+ doneOpen={doneOpen.has(d.key)}
234
+ onToggle={(id) => toggleOne(d.key, id)}
235
+ onToggleAll={(clear, pending) => toggleAll(d.key, pending, clear)}
236
+ onToggleDone={() => toggleDone(d.key)}
237
+ onOpenDir={() => onNavigate(dirBase(d.key))}
238
+ onOpenTask={(id) => openTask(dirBase(d.key), id)}
239
+ />
76
240
  );
77
241
  })}
242
+
243
+ {filter.size > 0 &&
244
+ !((globalRow && shown(GLOBAL_KEY) && ((globalRow.pending || 0) > 0 || globalRow.waiting)) ||
245
+ dirs.some((d) => shown(d.key) && (splitQueueTasks(d.tasks || []).pending.length || d.waiting))) && (
246
+ <div className="muted" style={{ padding: '12px 4px' }}>没有匹配当前筛选的目录。</div>
247
+ )}
248
+
249
+ {selCount > 0 && (
250
+ <div className="ov-bar" role="toolbar" aria-label="批量删除操作条">
251
+ <span className="ov-bar-n">
252
+ 已选 <b>{selCount}</b> 条任务
253
+ </span>
254
+ <span className="grow" />
255
+ <button type="button" className="mini" onClick={() => setSel(new Map())}>
256
+ 取消选择
257
+ </button>
258
+ <button type="button" className="btn small danger" onClick={dropAll}>
259
+ 删除选中 {selCount}
260
+ </button>
261
+ </div>
262
+ )}
78
263
  </div>
79
264
  );
80
265
  }
81
266
 
82
- function PendingList({ tasks }) {
267
+ /**
268
+ * 一个目录分区:标题(可点进落地页)+ 计数 + 全选 + 待领取行 + 可折叠的已领取历史。
269
+ * `selected` 是**已经与当前待领取取过交集**的编号数组,全选判定只看它。
270
+ */
271
+ function Sec({ secKey, icon, title, cwd, tasks, waiting, selected, doneOpen, onToggle, onToggleAll, onToggleDone, onOpenDir, onOpenTask }) {
83
272
  const split = splitQueueTasks(tasks || []);
84
- if (!split.pending.length) return <div className="ov-task muted">(队列为空)</div>;
273
+ const pending = split.pending;
274
+ const consumed = split.consumed;
275
+ const on = new Set(selected);
276
+ const allOn = pending.length > 0 && pending.every((t) => on.has(Number(t.id)));
277
+ const someOn = on.size > 0 && !allOn;
278
+
85
279
  return (
86
- <>
87
- {split.pending.map((t) => (
88
- <div className="ov-task" key={t.id}>
89
- <span className="mono faint">#{t.id}</span>
90
- <span className="tx" title={t.text}>{t.text}</span>
91
- <span className="m mono">{t.from === 'cloud' ? '来自云端' : ''}</span>
92
- </div>
280
+ <section className="ov-sec" data-sec={secKey}>
281
+ <div className="ov-sec-h">
282
+ <Icon name={icon} size={13} />
283
+ <button type="button" className="ov-sec-t" onClick={onOpenDir} title={`进入「${title}」`}>
284
+ {title}
285
+ </button>
286
+ {cwd ? <span className="c mono">{cwd}</span> : null}
287
+ <span className="c">{pending.length} 待领取</span>
288
+ {waiting && (
289
+ <span className="badge-wait">
290
+ <span className="pulse" /> 等待回填
291
+ </span>
292
+ )}
293
+ <span className="grow" />
294
+ {pending.length > 0 && (
295
+ <label className="ov-all">
296
+ <input
297
+ type="checkbox"
298
+ checked={allOn}
299
+ ref={(el) => {
300
+ if (el) el.indeterminate = someOn;
301
+ }}
302
+ onChange={() => onToggleAll(allOn, pending)}
303
+ aria-label={`全选「${title}」的待领取任务`}
304
+ />
305
+ 全选
306
+ </label>
307
+ )}
308
+ </div>
309
+
310
+ {!pending.length && <div className="ov-task muted">(队列为空)</div>}
311
+ {pending.map((t) => (
312
+ <TaskRow key={t.id} t={t} checked={on.has(Number(t.id))} onToggle={onToggle} onOpenTask={onOpenTask} />
93
313
  ))}
94
- </>
314
+
315
+ {consumed.length > 0 && (
316
+ <>
317
+ <button type="button" className="ov-done-toggle" onClick={onToggleDone} aria-expanded={doneOpen}>
318
+ <span className={'tri' + (doneOpen ? ' open' : '')} aria-hidden="true">
319
+ ▸
320
+ </span>
321
+ 已领取 {consumed.length}
322
+ </button>
323
+ {doneOpen &&
324
+ consumed.map((t) => (
325
+ <TaskRow
326
+ key={t.id}
327
+ t={t}
328
+ checked={on.has(Number(t.id))}
329
+ onToggle={onToggle}
330
+ onOpenTask={onOpenTask}
331
+ meta={t.consumedAt ? String(t.consumedAt).slice(5, 16) : ''}
332
+ />
333
+ ))}
334
+ </>
335
+ )}
336
+ </section>
337
+ );
338
+ }
339
+
340
+ /** 一行任务:勾选框 + 编号 + 标题 + (云端来源 tag)+ (时间)+ 状态 tag。整行可点进详情。 */
341
+ function TaskRow({ t, checked, onToggle, onOpenTask, meta = '' }) {
342
+ const tag = statusTag(t);
343
+ return (
344
+ <div
345
+ className="ov-task link"
346
+ role="button"
347
+ tabIndex={0}
348
+ title="打开任务详情"
349
+ onClick={() => onOpenTask(t.id)}
350
+ onKeyDown={(e) => {
351
+ if (e.key !== 'Enter' && e.key !== ' ') return;
352
+ e.preventDefault();
353
+ onOpenTask(t.id);
354
+ }}
355
+ >
356
+ <label className="ov-ck" onClick={(e) => e.stopPropagation()}>
357
+ <input type="checkbox" checked={checked} onChange={() => onToggle(Number(t.id))} aria-label={`选择 #${t.id}`} />
358
+ </label>
359
+ <span className="mono faint">#{t.id}</span>
360
+ <span className="tx" title={t.text}>
361
+ {titleFromMd(t.text, t.text || '未命名任务')}
362
+ </span>
363
+ {t.from === 'cloud' && <span className="tag t-cloud">云端</span>}
364
+ {meta ? <span className="m mono">{meta}</span> : null}
365
+ <span className={'tag ' + tag.cls}>{tag.label}</span>
366
+ </div>
95
367
  );
96
368
  }