nx-kv 0.22.0 → 0.24.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 (31) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +1 -1
  3. package/src/web/frontend/components/md/ack-rail.jsx +134 -4
  4. package/src/web/frontend/modules/board/ack-rail-pos.js +50 -0
  5. package/src/web/frontend/modules/md/style.css +23 -0
  6. package/src/web/frontend/page/dir/task.jsx +6 -1
  7. package/src/web/frontend/page/group/index.jsx +15 -0
  8. package/src/web/frontend/page/progress/index.jsx +171 -33
  9. package/src/web/frontend/panels.jsx +12 -4
  10. package/src/web/public/assets/{Introduction-G2Mw-KEH.js → Introduction-DGcd3YBk.js} +1 -1
  11. package/src/web/public/assets/{cloud-Dv2fhr8x.js → cloud-E33bm4CD.js} +1 -1
  12. package/src/web/public/assets/{dag-oJ7hnl1H.js → dag-ClRadqiu.js} +1 -1
  13. package/src/web/public/assets/{dir-CkCIG1NX.js → dir-Dj-3QRkO.js} +1 -1
  14. package/src/web/public/assets/group-Ckwoc5tW.js +1 -0
  15. package/src/web/public/assets/{index-CX3sgrm7.css → index--c-rj9Y8.css} +1 -1
  16. package/src/web/public/assets/{index-CrTd_Xvm.js → index-DMp4lRKl.js} +2 -2
  17. package/src/web/public/assets/{md-history-B1WBG-Rt.js → md-history-CwtrJcZo.js} +1 -1
  18. package/src/web/public/assets/{member-picker-LfIrRJdd.js → member-picker-BV1a-LiO.js} +1 -1
  19. package/src/web/public/assets/{overview-ZcU0DXJg.js → overview-CQW1MViJ.js} +1 -1
  20. package/src/web/public/assets/progress-CDiNmHVI.js +3 -0
  21. package/src/web/public/assets/{repos-GOP6cAdF.js → repos-BYFj2sjE.js} +1 -1
  22. package/src/web/public/assets/{route-crumb-B29yU-j2.js → route-crumb-DSgy8FTp.js} +1 -1
  23. package/src/web/public/assets/{settings-oCBbLwUI.js → settings-BoAQ5YXH.js} +1 -1
  24. package/src/web/public/assets/task-Br1K-bzV.js +5 -0
  25. package/src/web/public/assets/{template-slots-CmvT1pGq.js → template-slots-CMB5ZTZE.js} +1 -1
  26. package/src/web/public/assets/{templates-Dx6B9B9x.js → templates-DMVIPsdx.js} +1 -1
  27. package/src/web/public/assets/{upload-Cs-NDNy8.js → upload-CzfyPGrk.js} +1 -1
  28. package/src/web/public/index.html +2 -2
  29. package/src/web/public/assets/group-BobOLn0C.js +0 -1
  30. package/src/web/public/assets/progress-DtAd2xVh.js +0 -3
  31. package/src/web/public/assets/task-DBMOQqn8.js +0 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.24.0] - 2026-11-05 进程面板支持项目组
6
+
7
+ ### Added
8
+
9
+ - **进程面板支持按项目组聚合(任务 #56)**:组发布的任务散在成员项目各自的队列里,
10
+ 原来的进程面板锁定单个目录,看不见组的全景。现在:
11
+ - **组页**新增「进程」入口,打开即按组聚合——扫全部队列、收正文
12
+ `frontmatter.board` 指向本组的任务(与组页任务面板同一口径),卡片标题旁标出
13
+ 归属项目;非组任务不混入。
14
+ - **任务页**的工具栏「进程」按任务自身语义分流:任务带工作组语义(`board`)
15
+ 就按组打开,不带就仍按目录打开(现状不变)。
16
+ - **按项目筛选**:面板顶部一组 chips(「全部」+ 各成员项目,带计数),点击过滤
17
+ 进程卡,顶部统计(并发/跑/等/完/平均进度)随筛选范围联动。筛选是会话内状态,
18
+ 切组后自动回落「全部」。
19
+ - 跨队列批量删除按任务**所在队列**分桶逐桶提交(删除 API 按目录寻址),
20
+ 勾选键用 `topicKey:id` 复合键——不同队列的同号任务不会互相顶掉。
21
+
22
+ ## [0.23.0] - 2026-11-05 定位轨可拖动
23
+
24
+ ### Added
25
+
26
+ - **任务页 ack 定位轨支持拖动(任务 #55)**:按住汉堡钮把整条轨拖到正文区内任意位置,
27
+ 双击回默认钉位;落点存 localStorage,重开面板还在。单击(位移 <4px)仍是展开/收起,
28
+ 拖动不会误触;拖到容器边缘自动钳回界内。
29
+ - 实现注意一:钉位态的轨盒子被 `top/bottom` 双锚拉满(`bottom:44px` → 高 760),
30
+ 钳制必须用**临时解除底锚量出的自然高**(同帧同步量取再恢复,子元素本来就堆在
31
+ 顶部,无视觉跳动)——直接用拉伸盒高会把第一次拖动错钳到容器顶部。
32
+ - 实现注意二:落点存盘用**拖动会话里的最终落点**(`pointerup` 时 React 状态可能
33
+ 还未提交,读 state 会存回拖动前的旧位置,等于白拖)。
34
+ - 落点纯逻辑(钳制 + 存取)独立成 `modules/board/ack-rail-pos.js`,node 单测
35
+ `tests/unit/ack-rail-pos.test.mjs` 6 条;端到端 Playwright 探针(隔离 store +
36
+ 临时端口 7879 + 本机 Chrome)11 条断言全过:跟手、越界钳制、拖完即持久化、
37
+ 拖后 click 不误触、展开态拖动、重开还原、双击回默认位、零 console error。
38
+
5
39
  ## [0.22.0] - 2026-11-05 任务页 ack 定位轨
6
40
 
7
41
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,16 @@
8
8
  // 两种渲染态都要能用:
9
9
  // - 实时预览(.mdw-lp):块有 data-i,按序号直接取
10
10
  // - 阅读态(.mdw-preview):整篇一次性渲染,没有块元素,按「第 N 个 ack 标题」取
11
- import { useCallback, useEffect, useMemo, useState } from 'react';
11
+ //
12
+ // 轨可拖动(任务 #55):汉堡钮是拖把——按住拖整条轨(Pointer Events,>4px 算拖、
13
+ // 否则算点击),双击回默认钉位;落点经 modules/board/ack-rail-pos.js 钳制并
14
+ // 存 localStorage,重开面板还在。拖动语义只在任务页这层加,简介页轨不受影响。
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
16
  import { ackNodeTitle } from '../../modules/board/ack-nodes.js';
17
+ import { clampRailPos, loadRailPos, saveRailPos } from '../../modules/board/ack-rail-pos.js';
18
+
19
+ // 位移阈值:超过才算拖动(否则 pointerup 后的 click 照常展开/收起)
20
+ const DRAG_THRESHOLD = 4;
13
21
 
14
22
  /**
15
23
  * 在滚动容器里按块序号找到目标块。
@@ -46,6 +54,112 @@ function blockAt(root, idx, ackOrdinal) {
46
54
  export default function AckRail({ nodes = [], scrollRoot = null, onJump = null }) {
47
55
  const [open, setOpen] = useState(false);
48
56
  const [activeRound, setActiveRound] = useState(0);
57
+ // 拖动落点:null = 默认钉位(CSS);有值 = inline left/top 自由落点
58
+ const [pos, setPos] = useState(loadRailPos);
59
+ const [dragging, setDragging] = useState(false);
60
+ const railRef = useRef(null);
61
+ const dragRef = useRef(null); // 拖动会话:起点、容器/轨尺寸、是否已过阈值、最终落点
62
+ const draggedRef = useRef(false); // 刚结束的是拖动 → 吃掉紧随的 click,别误触展开
63
+
64
+ // 持久化落点钳回当前容器内(视口可能比上次拖动时小)
65
+ useEffect(() => {
66
+ const el = railRef.current;
67
+ const parent = el && el.parentElement;
68
+ if (!el || !parent) return undefined;
69
+ const fix = () => {
70
+ // 经 setPos updater 读最新落点(不另设 ref);钳制是纯函数,重复调用幂等
71
+ setPos((cur) => {
72
+ if (!cur) return cur;
73
+ const pr = parent.getBoundingClientRect();
74
+ const rr = el.getBoundingClientRect();
75
+ const fixed = clampRailPos({
76
+ x: cur.x, y: cur.y, parentW: pr.width, parentH: pr.height, railW: rr.width, railH: rr.height,
77
+ });
78
+ return fixed && (fixed.x !== cur.x || fixed.y !== cur.y) ? fixed : cur;
79
+ });
80
+ };
81
+ fix();
82
+ // 窗口缩放时也别出界
83
+ window.addEventListener('resize', fix, { passive: true });
84
+ return () => window.removeEventListener('resize', fix);
85
+ }, []);
86
+
87
+ // ─── 拖动(汉堡钮 = 拖把)────────────────────────────────────────
88
+ const beginDrag = useCallback((e) => {
89
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
90
+ const el = railRef.current;
91
+ const parent = el && el.parentElement;
92
+ if (!el || !parent) return;
93
+ // 钉位态盒子被 top/bottom 双锚拉满(bottom:44 → 高 760),实测高 ≠ 拖后自由态
94
+ // 自然高——拿它钳制会把上限错钳到容器顶部(第一次拖只能落在 y≤132)。
95
+ // 临时解除底锚量一次自然高再恢复:同帧同步、子元素本来就从顶部堆叠,无视觉跳动。
96
+ const prevBottom = el.style.bottom;
97
+ el.style.bottom = 'auto';
98
+ const rr = el.getBoundingClientRect();
99
+ if (prevBottom) el.style.bottom = prevBottom;
100
+ else el.style.bottom = '';
101
+ const pr = parent.getBoundingClientRect();
102
+ dragRef.current = {
103
+ pointerId: e.pointerId,
104
+ startX: e.clientX,
105
+ startY: e.clientY,
106
+ startLeft: rr.left - pr.left, // 轨在容器内的当前左/上(含钉位态)——位移在此基础上叠加
107
+ startTop: rr.top - pr.top,
108
+ parentW: pr.width,
109
+ parentH: pr.height,
110
+ railW: rr.width,
111
+ railH: rr.height, // 自然高(已解除底锚量取)
112
+ moved: false,
113
+ };
114
+ try {
115
+ e.currentTarget.setPointerCapture(e.pointerId);
116
+ } catch {
117
+ /* 老浏览器不支持捕获时照常工作(指针没离开按钮的话) */
118
+ }
119
+ }, []);
120
+
121
+ const onDragMove = useCallback((e) => {
122
+ const d = dragRef.current;
123
+ if (!d || e.pointerId !== d.pointerId) return;
124
+ const dx = e.clientX - d.startX;
125
+ const dy = e.clientY - d.startY;
126
+ if (!d.moved) {
127
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
128
+ d.moved = true;
129
+ draggedRef.current = true;
130
+ setDragging(true);
131
+ document.body.style.userSelect = 'none'; // 拖动别顺带选中正文
132
+ }
133
+ // 抓握点跟随手指:轨在容器内的落点 = 指针按下时的轨位置 + 指针位移
134
+ //(startLeft 按下时实测,grabDX 的偏移天然已含在内,不需要再修正)
135
+ const fixed = clampRailPos({
136
+ x: d.startLeft + dx,
137
+ y: d.startTop + dy,
138
+ parentW: d.parentW,
139
+ parentH: d.parentH,
140
+ railW: d.railW,
141
+ railH: d.railH,
142
+ });
143
+ if (fixed) {
144
+ d.last = fixed; // 最终落点记在会话里——pointerup 直接取,不等 React 提交
145
+ setPos(fixed);
146
+ }
147
+ }, []);
148
+
149
+ const endDrag = useCallback((e) => {
150
+ const d = dragRef.current;
151
+ if (!d || e.pointerId !== d.pointerId) return;
152
+ dragRef.current = null;
153
+ if (!d.moved) return; // 没过阈值 = 点击,交给 onClick 展开/收起
154
+ setDragging(false);
155
+ document.body.style.userSelect = '';
156
+ saveRailPos(d.last); // 拖完落点才存(拖动过程不写盘;存会话里的最终落点,不读可能未提交的 state)
157
+ }, []);
158
+
159
+ const resetPos = useCallback(() => {
160
+ setPos(null);
161
+ saveRailPos(null); // 清记录 = 回 CSS 默认钉位
162
+ }, []);
49
163
 
50
164
  const items = useMemo(() => (nodes || []).map((n) => ({ ...n, title: ackNodeTitle(n) })), [nodes]);
51
165
 
@@ -85,13 +199,29 @@ export default function AckRail({ nodes = [], scrollRoot = null, onJump = null }
85
199
  if (!items.length) return null;
86
200
 
87
201
  return (
88
- <aside className={'doc-rail ack-rail' + (open ? ' open' : '')} aria-label="验收轮次">
202
+ <aside
203
+ ref={railRef}
204
+ className={'doc-rail ack-rail' + (open ? ' open' : '') + (dragging ? ' dragging' : '')}
205
+ style={pos ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' } : undefined}
206
+ aria-label="验收轮次"
207
+ >
89
208
  <button
90
209
  type="button"
91
210
  className="doc-rail-menu"
92
- title={open ? '收起轮次' : '展开轮次'}
211
+ title={open ? '收起轮次(双击回默认位)' : '展开轮次 · 按住拖动 · 双击回默认位'}
93
212
  aria-expanded={open}
94
- onClick={() => setOpen((v) => !v)}
213
+ onClick={() => {
214
+ if (draggedRef.current) {
215
+ draggedRef.current = false; // 这下 click 是拖动结束带出来的,别当点击
216
+ return;
217
+ }
218
+ setOpen((v) => !v);
219
+ }}
220
+ onPointerDown={beginDrag}
221
+ onPointerMove={onDragMove}
222
+ onPointerUp={endDrag}
223
+ onPointerCancel={endDrag}
224
+ onDoubleClick={resetPos}
95
225
  >
96
226
  <span /><span /><span />
97
227
  </button>
@@ -0,0 +1,50 @@
1
+ // ack 定位轨的拖动落点(任务 #55):坐标钳制 + localStorage 持久化。
2
+ //
3
+ // 轨默认钉在任务正文区右上(.mdw > .ack-rail 的 CSS 顶/底);用户按住汉堡钮可把
4
+ // 整条轨拖到容器内任意位置,落点存 localStorage,重开面板还在。纯函数与存取都在
5
+ // 这一层,不依赖 DOM——指针事件编排在组件(components/md/ack-rail.jsx)里,
6
+ // 这层保持 node 单测可直接覆盖(localStorage 缺席时存取静默降级)。
7
+
8
+ const POS_KEY = 'nx-kv-ack-rail-pos';
9
+ export const RAIL_MARGIN = 8; // 落点距容器边缘的最小间距
10
+
11
+ /**
12
+ * 把落点钳回容器内(四边各留 margin)。容器比轨还窄/矮时,钳到 margin 为下限
13
+ * (宁可贴边也不出界)。任何输入非法 → 返回 null(调用方放弃本次更新)。
14
+ * @param {{x:number,y:number,parentW:number,parentH:number,railW?:number,railH?:number,margin?:number}} p
15
+ * @returns {{x:number,y:number}|null}
16
+ */
17
+ export function clampRailPos(p = {}) {
18
+ const { x, y, parentW, parentH, railW = 0, railH = 0, margin = RAIL_MARGIN } = p || {};
19
+ if (![x, y, parentW, parentH].every((n) => Number.isFinite(n))) return null;
20
+ const maxX = Math.max(margin, parentW - railW - margin);
21
+ const maxY = Math.max(margin, parentH - railH - margin);
22
+ return {
23
+ x: Math.min(maxX, Math.max(margin, x)),
24
+ y: Math.min(maxY, Math.max(margin, y)),
25
+ };
26
+ }
27
+
28
+ /** 读取持久化落点;无记录 / 脏数据 / localStorage 不可用 → null(回落到 CSS 默认位) */
29
+ export function loadRailPos() {
30
+ try {
31
+ const raw = JSON.parse(localStorage.getItem(POS_KEY) || 'null');
32
+ if (!raw || typeof raw !== 'object') return null;
33
+ const x = Number(raw.x);
34
+ const y = Number(raw.y);
35
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
36
+ return { x, y };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** 持久化落点;传 null 清掉(回到 CSS 默认位)。localStorage 不可用时静默跳过。 */
43
+ export function saveRailPos(pos) {
44
+ try {
45
+ if (!pos) localStorage.removeItem(POS_KEY);
46
+ else localStorage.setItem(POS_KEY, JSON.stringify({ x: Math.round(pos.x), y: Math.round(pos.y) }));
47
+ } catch {
48
+ /* 隐私模式等存不了就算了 */
49
+ }
50
+ }
@@ -650,6 +650,23 @@
650
650
  font-weight:600;font-size:11px;font-family:inherit;color:var(--mid,#838A93);
651
651
  }
652
652
  .mdw-prog-all:hover{background:var(--mdw-accent-soft);color:var(--mdw-accent)}
653
+ /* 组模式的项目筛选 chips(任务 #56):「全部」+ 各成员项目,点击过滤进程卡;
654
+ 数量小字用 <b>,选中态用 accent 描边 */
655
+ .mdw-prog-filters{
656
+ flex:none;display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;
657
+ border-bottom:1px solid var(--line,#E4E2DC);
658
+ }
659
+ .mdw-prog-fchip{
660
+ border:1px solid var(--line,#E4E2DC);background:var(--paper,#fff);color:var(--ink-2,#4B515A);
661
+ border-radius:14px;padding:3px 10px;cursor:pointer;font-size:11px;font-weight:600;font-family:inherit;
662
+ display:inline-flex;align-items:center;gap:4px;
663
+ }
664
+ .mdw-prog-fchip b{font-weight:700;font-size:10px;color:var(--mid,#838A93)}
665
+ .mdw-prog-fchip:hover{border-color:var(--mdw-accent);color:var(--mdw-accent)}
666
+ .mdw-prog-fchip.on{background:var(--mdw-accent-soft);border-color:var(--mdw-accent);color:var(--mdw-accent)}
667
+ .mdw-prog-fchip.on b{color:var(--mdw-accent)}
668
+ /* 组模式卡片标题旁的项目 tag(复用 rp-ptag 视觉,略收间距贴标题) */
669
+ .mdw-tcard-proj{margin-left:6px}
653
670
  /* 勾选后的操作条:已选 n 条 + 清除 + 删除所选 */
654
671
  .mdw-prog-sel{
655
672
  flex:none;display:flex;align-items:center;gap:6px;padding:7px 10px;
@@ -1059,6 +1076,12 @@
1059
1076
  .mdw 本身是 position:relative,absolute 轨锚在它上面。 */
1060
1077
  .mdw > .ack-rail{top:96px;bottom:44px}
1061
1078
  .mdw > .ack-rail .doc-rail-ticks{gap:12px}
1079
+ /* 可拖动(任务 #55):汉堡钮是拖把。touch-action:none 防触屏拖动被页面滚动劫持;
1080
+ 拖动态抬层 + grabbing 光标。落点落定后是 inline left/top(组件里写),right/bottom
1081
+ 置 auto 解除钉位——所以这里不写任何 top/right,默认位完全交给原规则。 */
1082
+ .mdw > .ack-rail .doc-rail-menu{cursor:grab;touch-action:none}
1083
+ .mdw > .ack-rail.dragging{z-index:80}
1084
+ .mdw > .ack-rail.dragging .doc-rail-menu{cursor:grabbing;background:var(--soft,#F1F0EC);color:var(--ink,#16181C)}
1062
1085
  .doc-rail-sec{padding:4px 0 8px;border-bottom:1px solid var(--line,#E4E2DC)}
1063
1086
  .doc-rail-sec:last-child{border-bottom:0;padding-bottom:4px}
1064
1087
  .doc-rail-sec-lab{
@@ -325,13 +325,18 @@ export default function PagePanel({ dirKey, task, beside = false, tabId, model }
325
325
  // 工具栏「进程」:面板化之后进程不再是内嵌右侧栏,而是工作区里独立的一块
326
326
  // (可拖动 / 可浮出 / 可单独关掉)。已开着同一目录的进程面板就聚焦它,不重复开。
327
327
  // 注意 config 要带上 dirKey:进程面板是按**目录**取任务的,缺了会落到全局池。
328
+ // 任务带工作组语义(frontmatter.board,任务 #56)→ 进程按**组**聚合打开:
329
+ // 组发布的任务散在成员项目各自的队列里,单目录视图看不见全景。
328
330
  const openProgress = () => {
329
331
  const m = model || app.model;
330
332
  if (!m) return;
333
+ const boardId = docFm && docFm.board ? String(docFm.board) : '';
331
334
  openPanel(m, {
332
335
  component: 'progress',
333
336
  name: '进程',
334
- config: { dirKey: dirKey || GLOBAL_KEY, task: task != null ? String(task) : undefined },
337
+ config: boardId
338
+ ? { groupKey: boardId, task: task != null ? String(task) : undefined }
339
+ : { dirKey: dirKey || GLOBAL_KEY, task: task != null ? String(task) : undefined },
335
340
  location: 'right',
336
341
  });
337
342
  };
@@ -228,6 +228,21 @@ export default function GroupView({ dirKey, rows, repoData, onNavigate, model })
228
228
  >
229
229
  + 新增任务
230
230
  </button>
231
+ <button
232
+ className="btn small ghost"
233
+ title="打开本组的任务进程面板:组内各项目队列聚合显示,可按项目筛选(任务 #56)"
234
+ onClick={() => {
235
+ if (!model) return;
236
+ openPanel(model, {
237
+ component: 'progress',
238
+ name: '进程',
239
+ config: { groupKey: dirKey },
240
+ location: 'right',
241
+ });
242
+ }}
243
+ >
244
+ 进程
245
+ </button>
231
246
  <button
232
247
  className="btn small ghost"
233
248
  title="挑项目加进这个组(可搜索项目与项目组)"