nx-kv 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -40
- package/package.json +1 -1
- package/src/core/workflow/project.js +9 -5
- package/src/modules/localtask/move-prims.js +4 -1
- package/src/modules/localtask/store-io.js +13 -0
- package/src/modules/localtask/task-lifecycle.js +5 -1
- package/src/modules/localtask/task-ops.js +5 -0
- package/src/modules/localtask/task-remove.js +6 -1
- package/src/web/frontend/components/md/ack-rail.jsx +133 -0
- package/src/web/frontend/modules/board/ack-nodes.js +89 -0
- package/src/web/frontend/modules/md/style.css +6 -0
- package/src/web/frontend/page/dir/task.jsx +24 -0
- package/src/web/public/assets/{Introduction-BSDhb2Sj.js → Introduction-G2Mw-KEH.js} +1 -1
- package/src/web/public/assets/{cloud-Sfi1OGsF.js → cloud-Dv2fhr8x.js} +1 -1
- package/src/web/public/assets/{dag-BnXCpGph.js → dag-oJ7hnl1H.js} +1 -1
- package/src/web/public/assets/{dir-DG-o4u5h.js → dir-CkCIG1NX.js} +1 -1
- package/src/web/public/assets/{group-CO1dZAIi.js → group-BobOLn0C.js} +1 -1
- package/src/web/public/assets/{index-Cg0WaaAi.css → index-CX3sgrm7.css} +1 -1
- package/src/web/public/assets/{index-BSQ-y9mw.js → index-CrTd_Xvm.js} +2 -2
- package/src/web/public/assets/{md-history-DwI1pcCg.js → md-history-B1WBG-Rt.js} +1 -1
- package/src/web/public/assets/{member-picker-BgVyjr7m.js → member-picker-LfIrRJdd.js} +1 -1
- package/src/web/public/assets/{overview-CdGfl5aQ.js → overview-ZcU0DXJg.js} +1 -1
- package/src/web/public/assets/{progress-CxIXeuOg.js → progress-DtAd2xVh.js} +1 -1
- package/src/web/public/assets/{repos-DiYt0y77.js → repos-GOP6cAdF.js} +1 -1
- package/src/web/public/assets/{route-crumb-BEAGbZBS.js → route-crumb-B29yU-j2.js} +1 -1
- package/src/web/public/assets/{settings-BIDVlTR_.js → settings-oCBbLwUI.js} +1 -1
- package/src/web/public/assets/task-DBMOQqn8.js +5 -0
- package/src/web/public/assets/{template-slots-B7SKP2-A.js → template-slots-CmvT1pGq.js} +1 -1
- package/src/web/public/assets/{templates-BOJCv21y.js → templates-Dx6B9B9x.js} +1 -1
- package/src/web/public/assets/{upload-ChrVGtCq.js → upload-Cs-NDNy8.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/task-5c2dyDzF.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.22.0] - 2026-11-05 任务页 ack 定位轨
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **任务页 ack 定位轨(任务 #48)**:反复 ack 反馈的任务(正文一轮一轮变长,#34 有 5 轮、
|
|
10
|
+
正文 13903 字符)在右侧多一条定位轨——**一轮一个节点**,点一下直接跳到那一轮的
|
|
11
|
+
「上一轮完成总结」。复用简介页那套定位轨的视觉(`.doc-rail`),但定位方式不同:
|
|
12
|
+
按**块序号**跳,不按标题锚点跳。
|
|
13
|
+
- 为什么不用标题锚点:实时预览态逐块渲染,每块各有一套标题 id 生成器,于是同名标题在
|
|
14
|
+
DOM 里拿到的是**重复的同一个 id**(两个「## 上一轮完成总结」都是同一个 id)——
|
|
15
|
+
按 id 跳永远落在第一轮。块序号(`data-i`)才唯一。
|
|
16
|
+
- 阅读态(整篇一次性渲染)没有块元素,改按「第 N 个 ack 标题」定位——该态标题 id 互不相同。
|
|
17
|
+
- 轮次从**正文**抽(每轮一个「## 上一轮完成总结」段):正文即历史,改完正文轨立刻跟着变。
|
|
18
|
+
- 只有一个节点时不显示(没有可跳的多轮,挂了也是噪音)。
|
|
19
|
+
|
|
5
20
|
## [0.21.0] - 2026-11-05 队列与编排的职责单一化 + store 损坏防护
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -42,51 +57,23 @@
|
|
|
42
57
|
- 闸门徽章升级:显示被挡后续数(`闸门×N`,悬停列出 #id)。
|
|
43
58
|
- 组任务行尾新增「⋮」行菜单(编排依赖 / 闸门开关),待领取行悬停可见。
|
|
44
59
|
|
|
60
|
+
- `localtask config --suffix-list "一条,二条"` / `--ack-list "一条,二条"`:**整体替换**多条
|
|
61
|
+
提示词列表(逗号分隔;HTTP body 传数组)。此前 action 只暴露单条 `--suffix`/`--ack`,
|
|
62
|
+
面板的多条保存无处可传(#41 实测发现并修)。
|
|
63
|
+
|
|
45
64
|
### Changed
|
|
46
65
|
|
|
47
66
|
- **设置面板观感重做(任务 #41 重开反馈「ui 太丑了」→ 二轮「更丑了」)**:交互语义不变,只收拾
|
|
48
67
|
密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;提示词条目行去描边、
|
|
49
68
|
编号不用圆圈、**收起标题洗掉原始模板标记**(不再直接甩 `{{#goal}}〔…〕` 给人看);操作条分隔线分组;
|
|
50
69
|
项目级设置从大卡片改成紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关;
|
|
51
|
-
「提示词 /
|
|
52
|
-
密度与层级——「提示词 / 模板」栏的竖排 tab 侧栏改成顶部一排药丸、内容限宽;
|
|
53
|
-
提示词条目行去描边、编号不用圆圈;操作条分隔线分组;项目级设置从大卡片改成
|
|
54
|
-
紧凑行(项目多时一屏 8 个而不是 2 个),「持续 get」关着时只留一枚行内小开关。
|
|
55
|
-
|
|
56
|
-
### Changed
|
|
70
|
+
「提示词 / 模板」栏不再套卡(盒中盒显脏)。
|
|
57
71
|
|
|
58
72
|
- **面包屑移到 maincontent 面板内顶部(任务 #44)**:路径条不再占窗口顶部通栏
|
|
59
73
|
(那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
|
|
60
74
|
任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
|
|
61
75
|
根部都有一条;MD 工作台里则直接放进顶部工具栏(`mdw-top`)内、标题左侧;
|
|
62
|
-
「←
|
|
63
|
-
(那是侧栏的地盘),改成挂在**当前主面板**顶部的一枚小药丸——目录任务表 /
|
|
64
|
-
任务详情页 / 项目组 / 全部目录总览 / 云端总览 / 仓库管理 / 任务模板 各面板
|
|
65
|
-
根部都有一条;「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
|
|
66
|
-
|
|
67
|
-
### Fixed
|
|
68
|
-
|
|
69
|
-
- **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
|
|
70
|
-
此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
|
|
71
|
-
正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
|
|
72
|
-
看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
|
|
73
|
-
/ 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
|
|
74
|
-
哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
|
|
75
|
-
约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
|
|
76
|
-
跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
|
|
77
|
-
跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
|
|
78
|
-
另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
|
|
79
|
-
保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
|
|
80
|
-
|
|
81
|
-
- **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
|
|
82
|
-
FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
|
|
83
|
-
(侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
|
|
84
|
-
侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
|
|
85
|
-
面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
|
|
86
|
-
从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
|
|
87
|
-
而不是掉进目录;没有上一级时不画返回键。
|
|
88
|
-
|
|
89
|
-
### Changed
|
|
76
|
+
「← 返回」随路径条一起搬进面板(按来处返回的行为不变)。
|
|
90
77
|
|
|
91
78
|
- **可视化编排面板(任务 #40 验收反馈重开)**:目录与项目组**共用**一块编排面板,
|
|
92
79
|
从任务面板**头部右侧的绿色超链**进入(开在右侧主内容区):
|
|
@@ -107,13 +94,27 @@
|
|
|
107
94
|
- **项目级「持续 get」一句话配置**:选模板(内置默认 + 全局回调提示词条目)→ 改一句话
|
|
108
95
|
(`%s` = 任务编号,存取时与 `{{task.id}}` 互转)→ 添加;不再手写整段模板。
|
|
109
96
|
|
|
110
|
-
###
|
|
97
|
+
### Fixed
|
|
111
98
|
|
|
112
|
-
-
|
|
113
|
-
|
|
114
|
-
|
|
99
|
+
- **改归属项目即搬家(任务 #43「提交任务后发现归属项目填错了,重新修改提交无效」)**:
|
|
100
|
+
此前「归属决定落点」只在提交那一刻生效——提交后发现归属填错,改正文里的 project 再保存,
|
|
101
|
+
正文改了队列没动:任务还挂在旧项目队列里,旧项目的 agent 照旧领走,新项目的 agent 永远
|
|
102
|
+
看不见,组面板/目录页也都显示旧归属。现在 `localtask update --text` / `localtask reopen --text`
|
|
103
|
+
/ 面板「保存并同步 / 重做」走与派任务同一套落点判定:effective frontmatter.project 解析到
|
|
104
|
+
哪个登记项目,任务就迁到哪个队列;介绍引用(projectIntro/projectDesc)随归属刷新。
|
|
105
|
+
约束如实说明:已领取的任务不能改归属(要改先 reopen);带队列内依赖/闸门的任务搬运会
|
|
106
|
+
跨队列断链,先解除再改(明确报错,不静默);搬家后任务拿到**新队列的 id**(面板页签自动
|
|
107
|
+
跟到新号,CLI 输出标注迁移);指向它的跨队列引用(`topicKey:id`)改写到新地址。
|
|
108
|
+
另自愈历史脏数据:归属字段与所在队列对不上(上一轮只改字段没搬队列的残留)时,
|
|
109
|
+
保存即搬到归属队列;已领取/带队列内依赖的脏数据不搬也不拦普通编辑。
|
|
115
110
|
|
|
116
|
-
|
|
111
|
+
- **顶部面包屑真正可见 + 补返回键(任务 #42)**:面包屑此前**渲染了却看不见**——
|
|
112
|
+
FlexLayout 的根是绝对定位(inset:0),铺满工作区把 26px 的路径条盖在下面
|
|
113
|
+
(侧栏面板压在上面,路径条点不到也看不见)。现在工作区有面包屑时布局整体下移一条;
|
|
114
|
+
侧栏也不再出页签条(#33「无页签化」的残留,页签条同样压在路径条上)。
|
|
115
|
+
面包屑条左侧新增「← 返回」:按**来处**返回(会话内路由历史栈)——
|
|
116
|
+
从项目组点任务(任务住在 dir 队列里,详情页落在 dir 作用域)时,返回会回到**组**
|
|
117
|
+
而不是掉进目录;没有上一级时不画返回键。
|
|
117
118
|
|
|
118
119
|
- **首次打开设置面板爆栈**(任务 #41 实测发现,#33 遗留):`openPanel` 按区域分发时
|
|
119
120
|
FLOAT → `openFloatingPanel` → `openPanel`(未带 area)→ 又判成 FLOAT → 无限递归
|
|
@@ -575,7 +576,6 @@
|
|
|
575
576
|
- **实时预览块级查找**:Ctrl/⌘F 在预览态可用,n/m 计数 + 命中块高亮 + 滚动定位
|
|
576
577
|
- **无障碍**:编辑区 aria-label 补齐、状态栏小字对比度提到 WCAG AA
|
|
577
578
|
|
|
578
|
-
|
|
579
579
|
- **仓库获得全局唯一 key,导入导出改以 key 为身份(v3)+ 全屏导入导出面板**:
|
|
580
580
|
- **key**:归一化远程地址派生(`git@c:o/r.git`、`https://c/o/r`、
|
|
581
581
|
`ssh://git@ssh.github.com:443/o/r.git`、带端口、带 `.git` 都是同一个
|
package/package.json
CHANGED
|
@@ -64,16 +64,20 @@ export function applySnapshotToTopic(topic, snap) {
|
|
|
64
64
|
syncTaskFromNode(task, node);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy
|
|
67
|
+
// 边 → blockBy(显式依赖);闸门边由 blocksFollowing 表达,不写进每条 blockBy。
|
|
68
|
+
// 本地依赖取「现有 ∪ 快照边」的**并集**而不是按边重建:快照可能滞后于外部任务行写
|
|
69
|
+
// (引擎在途写),按边重建会把外部刚设好的 blockBy 洗掉。依赖是「只能多、不能凭空少」
|
|
70
|
+
// 的保守口径——解除依赖一律走外部 clear 先落盘,再由 syncTaskDagUpdate 拆边,
|
|
71
|
+
// 不存在「只删引擎边、不删任务行」的路径,所以并集不会让解除失效。
|
|
68
72
|
const gateIds = new Set(
|
|
69
73
|
(snap.nodes || []).filter((n) => isGateNode(n)).map((n) => String(n.id)),
|
|
70
74
|
);
|
|
71
75
|
|
|
72
76
|
for (const task of tasks) {
|
|
73
77
|
if (task.deletedAt || task.migratedTo) continue;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
);
|
|
78
|
+
const keep = normalizeBlockBy(task.blockBy, task.id);
|
|
79
|
+
const keepForeign = keep.filter((d) => parseDepRef(d)?.kind === 'foreign');
|
|
80
|
+
const keepLocal = keep.filter((d) => parseDepRef(d)?.kind === 'local');
|
|
77
81
|
const incoming = (snap.edges || []).filter((e) => String(e.to) === String(task.id));
|
|
78
82
|
const localDeps = [];
|
|
79
83
|
for (const e of incoming) {
|
|
@@ -82,7 +86,7 @@ export function applySnapshotToTopic(topic, snap) {
|
|
|
82
86
|
const n = Number(e.from);
|
|
83
87
|
if (Number.isFinite(n) && n > 0) localDeps.push(n);
|
|
84
88
|
}
|
|
85
|
-
task.blockBy = normalizeBlockBy([...localDeps, ...keepForeign], task.id);
|
|
89
|
+
task.blockBy = normalizeBlockBy([...keepLocal, ...localDeps, ...keepForeign], task.id);
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
topic.workflowRevision = Number(snap.revision) || 0;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 迁出 tombstone(目标端写成功后才调用)、撤销迁出、云端回插待领取首位。
|
|
3
3
|
import { badInput } from '../../core/errors.js';
|
|
4
4
|
import { normalizeCwd, resolveTaskKey } from './paths.js';
|
|
5
|
-
import { loadStore, saveStore, stampNow, normalizeText, topicOf, nextTaskId } from './store-io.js';
|
|
5
|
+
import { loadStore, saveStore, stampNow, normalizeText, topicOf, nextTaskId, bumpWorkflowRevision } from './store-io.js';
|
|
6
6
|
import { normalizeTask, isPending } from './task-view.js';
|
|
7
7
|
// ─── 迁移原语(task move 用;store 封装不外泄)─────────────────────
|
|
8
8
|
//
|
|
@@ -43,6 +43,7 @@ export async function markTaskMigrated({ cwd: rawCwd, key: rawKey, id, migratedT
|
|
|
43
43
|
}
|
|
44
44
|
raw.migratedTo = { side: 'cloud', topic: String(migratedTo.topic || ''), targetId: Number(migratedTo.targetId) || 0, at: String(migratedTo.at || stampNow()) };
|
|
45
45
|
t.updatedAt = stampNow();
|
|
46
|
+
bumpWorkflowRevision(t);
|
|
46
47
|
await saveStore(store);
|
|
47
48
|
return { status: 'ok', key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
|
|
48
49
|
}
|
|
@@ -70,6 +71,7 @@ export async function clearTaskMigrated({ cwd: rawCwd, key: rawKey, id, toTop =
|
|
|
70
71
|
t.tasks.unshift(raw);
|
|
71
72
|
}
|
|
72
73
|
t.updatedAt = stampNow();
|
|
74
|
+
bumpWorkflowRevision(t);
|
|
73
75
|
await saveStore(store);
|
|
74
76
|
return { status: 'ok', restored: true, key, cwd: t.cwd || '', name: t.name, task: normalizeTask(raw) };
|
|
75
77
|
}
|
|
@@ -90,6 +92,7 @@ export async function insertTaskAtTop({ cwd: rawCwd, key: rawKey, text, from = '
|
|
|
90
92
|
if (firstPending >= 0) t.tasks.splice(firstPending, 0, task);
|
|
91
93
|
else t.tasks.unshift(task);
|
|
92
94
|
t.updatedAt = stampNow();
|
|
95
|
+
bumpWorkflowRevision(t);
|
|
93
96
|
await saveStore(store);
|
|
94
97
|
return { status: 'ok', key, cwd: t.cwd || cwd, name: t.name, task };
|
|
95
98
|
}
|
|
@@ -248,6 +248,19 @@ export function nextTaskId(tasks) {
|
|
|
248
248
|
}
|
|
249
249
|
return max + 1;
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
// 外部写(不经引擎 Mutator)落盘前把主题的 workflowRevision 顶一格。
|
|
253
|
+
// 为什么必须:引擎侧落盘有 revision CAS(saveAtomic),但这条 CAS 只对「引擎自己的写」
|
|
254
|
+
// 成立——外部改任务行(设 blockBy / 领取 / 完成 / 删除)不动 revision,引擎**在途**的
|
|
255
|
+
// 过期快照(scheduler 定时器 / close 没等到的 runOnce)落盘时 CAS 照样通过,
|
|
256
|
+
// applySnapshotToTopic 会把过期边**重建**回全队列 blockBy,把刚写进去的依赖洗掉
|
|
257
|
+
// (现场:CI 上 localtask-attribution「历史脏数据但带本地依赖」偶发搬家,就是依赖被
|
|
258
|
+
// 在途写洗空后守卫放行)。顶一格让过期快照必然 CAS 冲突 → fail-open 丢弃;
|
|
259
|
+
// 引擎写全是任务行的可重投影,丢了无副作用,下一次 sync 会按新任务行重新投影。
|
|
260
|
+
export function bumpWorkflowRevision(topic) {
|
|
261
|
+
if (!topic) return;
|
|
262
|
+
topic.workflowRevision = (Number(topic.workflowRevision) || 0) + 1;
|
|
263
|
+
}
|
|
251
264
|
// "YYYY-MM-DD HH:mm:ss"(本地)→ epoch ms;无法解析返回 NaN
|
|
252
265
|
export function parseStampMs(s) {
|
|
253
266
|
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(String(s || ''));
|
|
@@ -6,7 +6,7 @@ import { formatDuration } from '../../core/instruction-template.js';
|
|
|
6
6
|
import { preserveFrontmatter } from '../../shared/frontmatter.js';
|
|
7
7
|
import { findFirstReadyPending } from './dag.js';
|
|
8
8
|
import { isGlobalKey, resolveTaskKey } from './paths.js';
|
|
9
|
-
import { loadStore, saveStore, stampNow, normalizeSummary, normalizeText } from './store-io.js';
|
|
9
|
+
import { loadStore, saveStore, stampNow, normalizeSummary, normalizeText, bumpWorkflowRevision } from './store-io.js';
|
|
10
10
|
import { normalizeTask, foreignResolver, isPending, relocateInStore } from './task-view.js';
|
|
11
11
|
import { markConsumed } from './waiters.js';
|
|
12
12
|
// 把一条任务重新开放(用户的**修改反馈** → 重新进入执行循环)。
|
|
@@ -51,6 +51,8 @@ export async function reopenTask({ cwd: rawCwd, key: rawKey, id, text, moveTo }
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
host.updatedAt = stampNow();
|
|
54
|
+
bumpWorkflowRevision(host);
|
|
55
|
+
if (host !== t) bumpWorkflowRevision(t);
|
|
54
56
|
await saveStore(store);
|
|
55
57
|
return {
|
|
56
58
|
status: 'ok',
|
|
@@ -81,6 +83,7 @@ export async function consumeFirstPending(key, roundSummary, by) {
|
|
|
81
83
|
byName: by && by.name,
|
|
82
84
|
});
|
|
83
85
|
t.updatedAt = stampNow();
|
|
86
|
+
bumpWorkflowRevision(t);
|
|
84
87
|
await saveStore(store);
|
|
85
88
|
return normalizeTask(task);
|
|
86
89
|
}
|
|
@@ -145,6 +148,7 @@ export async function completeTask({ cwd: rawCwd, key: rawKey, id, summary: rawS
|
|
|
145
148
|
task.completedAt = stampNow();
|
|
146
149
|
task.roundSummary = summary;
|
|
147
150
|
t.updatedAt = stampNow();
|
|
151
|
+
bumpWorkflowRevision(t);
|
|
148
152
|
await saveStore(store);
|
|
149
153
|
// 单一写路径:完成 → Mutator 标 success + 调度一轮(fail-open)
|
|
150
154
|
try {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
normalizeText,
|
|
15
15
|
topicOf,
|
|
16
16
|
nextTaskId,
|
|
17
|
+
bumpWorkflowRevision,
|
|
17
18
|
} from './store-io.js';
|
|
18
19
|
import { GLOBAL_KEY, GLOBAL_NAME, GLOBAL_CWD, isGlobalKey, normalizeCwd, resolveTaskKey, topicKeyFor } from './paths.js';
|
|
19
20
|
import { normalizeTask, foreignResolver, queueRelationsOf, relocateInStore } from './task-view.js';
|
|
@@ -131,6 +132,7 @@ export async function addTask({
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
bumpWorkflowRevision(t);
|
|
134
136
|
await saveStore(store); // 先落盘再唤醒:wait 返回时数据必然一致
|
|
135
137
|
if (wake) wake(normalizeTask(task));
|
|
136
138
|
|
|
@@ -273,6 +275,9 @@ export async function updateTask({
|
|
|
273
275
|
}
|
|
274
276
|
host.updatedAt = stampNow();
|
|
275
277
|
if (host !== t) t.updatedAt = stampNow();
|
|
278
|
+
// 搬家动了两个队列,源/目标都要顶 revision(引擎在途快照对两个都可能过期)
|
|
279
|
+
bumpWorkflowRevision(host);
|
|
280
|
+
if (host !== t) bumpWorkflowRevision(t);
|
|
276
281
|
await saveStore(store);
|
|
277
282
|
if (hasDagInput) {
|
|
278
283
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 与 purge 彻底摘除两套词汇;reorder 只在 pending 子序列内换位。
|
|
3
3
|
import { badInput } from '../../core/errors.js';
|
|
4
4
|
import { normalizeCwd, resolveTaskKey } from './paths.js';
|
|
5
|
-
import { loadStore, saveStore, stampNow } from './store-io.js';
|
|
5
|
+
import { loadStore, saveStore, stampNow, bumpWorkflowRevision } from './store-io.js';
|
|
6
6
|
import { normalizeTask, topicView } from './task-view.js';
|
|
7
7
|
export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false } = {}) {
|
|
8
8
|
const key = resolveTaskKey({ key: rawKey, cwd: rawCwd });
|
|
@@ -22,6 +22,7 @@ export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false }
|
|
|
22
22
|
// 彻底清除:把行真的摘出去(不可恢复)。软删除区里的"清干净"用这个。
|
|
23
23
|
t.tasks.splice(index, 1);
|
|
24
24
|
t.updatedAt = stampNow();
|
|
25
|
+
bumpWorkflowRevision(t);
|
|
25
26
|
await saveStore(store);
|
|
26
27
|
return { status: 'ok', purged: true, key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(task) };
|
|
27
28
|
}
|
|
@@ -32,6 +33,7 @@ export async function removeTask({ cwd: rawCwd, key: rawKey, id, purge = false }
|
|
|
32
33
|
task.deletedAt = stampNow();
|
|
33
34
|
task.trashIndex = index; // 恢复时放回原位
|
|
34
35
|
t.updatedAt = stampNow();
|
|
36
|
+
bumpWorkflowRevision(t);
|
|
35
37
|
await saveStore(store);
|
|
36
38
|
return { status: 'ok', purged: false, key, cwd: t.cwd || cwd, name: t.name, removed: normalizeTask(task) };
|
|
37
39
|
}
|
|
@@ -77,6 +79,7 @@ export async function removeTasks({ cwd: rawCwd, key: rawKey, ids, purge = false
|
|
|
77
79
|
}
|
|
78
80
|
if (removed.length) {
|
|
79
81
|
t.updatedAt = stampNow();
|
|
82
|
+
bumpWorkflowRevision(t);
|
|
80
83
|
await saveStore(store);
|
|
81
84
|
}
|
|
82
85
|
return {
|
|
@@ -114,6 +117,7 @@ export async function restoreTask({ cwd: rawCwd, key: rawKey, id } = {}) {
|
|
|
114
117
|
task.deletedAt = '';
|
|
115
118
|
delete task.trashIndex;
|
|
116
119
|
t.updatedAt = stampNow();
|
|
120
|
+
bumpWorkflowRevision(t);
|
|
117
121
|
await saveStore(store);
|
|
118
122
|
return { status: 'ok', restored: true, key, cwd: t.cwd || cwd, name: t.name, task: normalizeTask(task) };
|
|
119
123
|
}
|
|
@@ -161,6 +165,7 @@ export async function reorderTasks({ cwd: rawCwd, key: rawKey, id, beforeId, aft
|
|
|
161
165
|
const ti = t.tasks.indexOf(target); // 删除 drag 后目标下标自动修正
|
|
162
166
|
t.tasks.splice(hasBefore ? ti : ti + 1, 0, drag);
|
|
163
167
|
t.updatedAt = stampNow();
|
|
168
|
+
bumpWorkflowRevision(t);
|
|
164
169
|
|
|
165
170
|
await saveStore(store);
|
|
166
171
|
return { status: 'ok', key, cwd: t.cwd || '', name: t.name, topic: topicView(t) };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 任务页的「ack 定位轨」——反复 ack 反馈的长任务页里,一键跳到某一轮。
|
|
2
|
+
//
|
|
3
|
+
// 复用的是简介页那套定位轨(components/Introduction/rail.jsx 的 .doc-rail 视觉),
|
|
4
|
+
// 但**定位方式不同**:简介页按标题锚点跳,本轨按**块序号**跳。
|
|
5
|
+
// 原因见 modules/board/ack-nodes.js 的注释——实时预览态逐块渲染,同名标题在 DOM 里
|
|
6
|
+
// 拿到的是重复的同一个 id,按 id 跳永远落在第一轮;块序号(data-i)才唯一。
|
|
7
|
+
//
|
|
8
|
+
// 两种渲染态都要能用:
|
|
9
|
+
// - 实时预览(.mdw-lp):块有 data-i,按序号直接取
|
|
10
|
+
// - 阅读态(.mdw-preview):整篇一次性渲染,没有块元素,按「第 N 个 ack 标题」取
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
12
|
+
import { ackNodeTitle } from '../../modules/board/ack-nodes.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 在滚动容器里按块序号找到目标块。
|
|
16
|
+
*
|
|
17
|
+
* 两种渲染态的 DOM 形状不同:
|
|
18
|
+
* - 实时预览(.mdw-lp):每块一个 .mdw-lp-block,带 data-i → 直接取
|
|
19
|
+
* - 阅读态(.mdw-preview):整篇一次性渲染成一段 HTML,**没有块元素**;
|
|
20
|
+
* 此时改用「第 N 个 ack 标题元素」定位(同名标题在阅读态是整篇共享 usedIds,
|
|
21
|
+
* id 各不相同,所以能按序号取到)
|
|
22
|
+
*
|
|
23
|
+
* @param {Element|null} root 滚动容器
|
|
24
|
+
* @param {number} idx 块序号
|
|
25
|
+
* @param {number} ackOrdinal 该轮是第几个 ack(0 基;阅读态按标题序号取)
|
|
26
|
+
* @returns {Element|null}
|
|
27
|
+
*/
|
|
28
|
+
function blockAt(root, idx, ackOrdinal) {
|
|
29
|
+
if (!root || idx < 0) return null;
|
|
30
|
+
// 实时预览:每块带 data-i
|
|
31
|
+
const live = root.querySelector(`.mdw-lp-block[data-i="${idx}"]`);
|
|
32
|
+
if (live) return live;
|
|
33
|
+
// 阅读态:整篇渲染 → 按「第 N 个 ack 标题」取
|
|
34
|
+
const heads = [...root.querySelectorAll('h2')].filter(
|
|
35
|
+
(h) => (h.textContent || '').trim() === '上一轮完成总结'
|
|
36
|
+
);
|
|
37
|
+
if (heads[ackOrdinal]) return heads[ackOrdinal];
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{nodes: {round:number, blockIndex:number, feedbackBlockIndex:number, ackText:string}[],
|
|
43
|
+
* scrollRoot: Element|null,
|
|
44
|
+
* onJump?: (node:object)=>void}} props
|
|
45
|
+
*/
|
|
46
|
+
export default function AckRail({ nodes = [], scrollRoot = null, onJump = null }) {
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [activeRound, setActiveRound] = useState(0);
|
|
49
|
+
|
|
50
|
+
const items = useMemo(() => (nodes || []).map((n) => ({ ...n, title: ackNodeTitle(n) })), [nodes]);
|
|
51
|
+
|
|
52
|
+
// 当前视口里最近的一个 ack 段 → 高亮对应节点
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const root = scrollRoot;
|
|
55
|
+
if (!root || !items.length) return undefined;
|
|
56
|
+
const sync = () => {
|
|
57
|
+
const rootRect = root.getBoundingClientRect();
|
|
58
|
+
let cur = 0;
|
|
59
|
+
for (const it of items) {
|
|
60
|
+
const el = blockAt(root, it.blockIndex, it.round - 1);
|
|
61
|
+
if (!el) continue;
|
|
62
|
+
const r = el.getBoundingClientRect();
|
|
63
|
+
if (r.top - rootRect.top <= 40) cur = it.round;
|
|
64
|
+
}
|
|
65
|
+
setActiveRound(cur);
|
|
66
|
+
};
|
|
67
|
+
sync();
|
|
68
|
+
root.addEventListener('scroll', sync, { passive: true });
|
|
69
|
+
return () => root.removeEventListener('scroll', sync);
|
|
70
|
+
}, [scrollRoot, items]);
|
|
71
|
+
|
|
72
|
+
const jump = useCallback(
|
|
73
|
+
(node) => {
|
|
74
|
+
const root = scrollRoot;
|
|
75
|
+
const el = root ? blockAt(root, node.blockIndex, node.round - 1) : null;
|
|
76
|
+
if (el) {
|
|
77
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
78
|
+
setActiveRound(node.round);
|
|
79
|
+
}
|
|
80
|
+
onJump?.(node);
|
|
81
|
+
},
|
|
82
|
+
[scrollRoot, onJump]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!items.length) return null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<aside className={'doc-rail ack-rail' + (open ? ' open' : '')} aria-label="验收轮次">
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className="doc-rail-menu"
|
|
92
|
+
title={open ? '收起轮次' : '展开轮次'}
|
|
93
|
+
aria-expanded={open}
|
|
94
|
+
onClick={() => setOpen((v) => !v)}
|
|
95
|
+
>
|
|
96
|
+
<span /><span /><span />
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
<div className="doc-rail-ticks" role="navigation" aria-label="验收轮次">
|
|
100
|
+
{items.map((it) => (
|
|
101
|
+
<button
|
|
102
|
+
key={it.round}
|
|
103
|
+
type="button"
|
|
104
|
+
className={'doc-rail-tick' + (activeRound === it.round ? ' on' : '')}
|
|
105
|
+
style={{ '--tw': '18px' }}
|
|
106
|
+
title={it.title}
|
|
107
|
+
aria-current={activeRound === it.round ? 'true' : undefined}
|
|
108
|
+
onClick={() => jump(it)}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{open && (
|
|
114
|
+
<div className="doc-rail-panel">
|
|
115
|
+
<div className="doc-rail-sec">
|
|
116
|
+
<div className="doc-rail-sec-lab">验收轮次</div>
|
|
117
|
+
{items.map((it) => (
|
|
118
|
+
<button
|
|
119
|
+
key={it.round}
|
|
120
|
+
type="button"
|
|
121
|
+
className={'doc-rail-row' + (activeRound === it.round ? ' on' : '')}
|
|
122
|
+
title={it.ackText}
|
|
123
|
+
onClick={() => jump(it)}
|
|
124
|
+
>
|
|
125
|
+
第 {it.round} 轮
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</aside>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 任务正文的「ack 节点」抽取 —— 反复 ack 反馈的长任务页要能快速定位到某一轮。
|
|
2
|
+
//
|
|
3
|
+
// 背景(任务 #48):用户给了 DOM 定位器(`.doc-rail-ticks` 那个小定位轨),
|
|
4
|
+
// 说「这个功能可以快速定位,而对于一些反复 ack 反馈的页面,这些任务页面很长,
|
|
5
|
+
// 操作不便捷,想要复用这个组件,将每一次 ack 作为一个节点去快速定位」。
|
|
6
|
+
//
|
|
7
|
+
// 事实基础:每次「重做(带反馈)」都会把上一轮的 ack 与本轮反馈**追加**进任务正文
|
|
8
|
+
// (见 modules/board/reopen-text.js 的 buildReopenText),所以正文里会出现**多个**
|
|
9
|
+
// `## 上一轮完成总结` / `## 验收反馈` 段。任务 #34 就是 5 轮、正文 13903 字符。
|
|
10
|
+
//
|
|
11
|
+
// 为什么不能直接用 extractMdHeadings 的标题 id:它给同名标题生成 `上一轮完成总结` /
|
|
12
|
+
// `-2` / `-3`… 的 id,但**实时预览态**(mdw-lp)是**逐块渲染**的——每块各自调
|
|
13
|
+
// renderMarkdownHtml、各有一套 usedIds,于是同名标题在 DOM 里拿到的是**重复的同一个
|
|
14
|
+
// id**(实测两个 `## 上一轮完成总结` 都是 id="上一轮完成总结")。按 id 跳转会永远落在
|
|
15
|
+
// 第一轮。所以这里按**块序号**定位:块切分口径直接复用 md-parse 的 liveBodyBlocks
|
|
16
|
+
// (与实时预览渲染用的是同一个函数),序号才对得上 DOM 里的 data-i。
|
|
17
|
+
//
|
|
18
|
+
// 纯函数、零依赖(只 import 同为纯前端的 md-parse.js):前端取节点、单测都能用。
|
|
19
|
+
|
|
20
|
+
import { liveBodyBlocks } from '../md/md-parse.js';
|
|
21
|
+
|
|
22
|
+
/** ack 段标题(与 reopen-text.js 同口径) */
|
|
23
|
+
export const ACK_NODE_HEADING = '## 上一轮完成总结';
|
|
24
|
+
/** 反馈段标题 */
|
|
25
|
+
export const ACK_NODE_FEEDBACK_HEADING = '## 验收反馈';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从任务正文里抽出每一轮 ack 节点。
|
|
29
|
+
*
|
|
30
|
+
* @param {string} body 任务正文(Markdown)
|
|
31
|
+
* @returns {{ round:number, blockIndex:number, feedbackBlockIndex:number,
|
|
32
|
+
* ackText:string, feedbackText:string }[]}
|
|
33
|
+
* round 从 1 起。blockIndex 是该轮 ack 段标题在**正文块序列**里的序号(0 基,
|
|
34
|
+
* 对应实时预览的 data-i);feedbackBlockIndex 同理,找不到为 -1。
|
|
35
|
+
*/
|
|
36
|
+
export function extractAckNodes(body) {
|
|
37
|
+
const blocks = liveBodyBlocks(String(body ?? ''));
|
|
38
|
+
if (!blocks.length) return [];
|
|
39
|
+
|
|
40
|
+
const out = [];
|
|
41
|
+
// cur = 正在填充的节点;phase = 当前内容属于它的哪一段
|
|
42
|
+
let cur = null;
|
|
43
|
+
let phase = '';
|
|
44
|
+
|
|
45
|
+
blocks.forEach((raw, i) => {
|
|
46
|
+
const b = String(raw || '').trim();
|
|
47
|
+
if (b === ACK_NODE_HEADING) {
|
|
48
|
+
cur = {
|
|
49
|
+
round: out.length + 1,
|
|
50
|
+
blockIndex: i,
|
|
51
|
+
feedbackBlockIndex: -1,
|
|
52
|
+
ackText: '',
|
|
53
|
+
feedbackText: '',
|
|
54
|
+
};
|
|
55
|
+
out.push(cur);
|
|
56
|
+
phase = 'ack';
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (b === ACK_NODE_FEEDBACK_HEADING) {
|
|
60
|
+
// 反馈段归属**当前正在填的 ack 段**(没有则忽略——孤儿反馈段不算节点)
|
|
61
|
+
if (cur && phase === 'ack') {
|
|
62
|
+
cur.feedbackBlockIndex = i;
|
|
63
|
+
phase = 'feedback';
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!b || !cur) return;
|
|
68
|
+
if (phase === 'ack') cur.ackText += (cur.ackText ? '\n' : '') + b;
|
|
69
|
+
else cur.feedbackText += (cur.feedbackText ? '\n' : '') + b;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return out.filter((n) => n.ackText !== '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 给节点起一个短标题(轨上显示用):取 ack 首行(去掉 markdown 记号),
|
|
77
|
+
* 空则退到「第 N 轮」。过长截断。
|
|
78
|
+
* @param {{round:number, ackText:string}} node
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function ackNodeTitle(node) {
|
|
82
|
+
const first = String(node?.ackText || '')
|
|
83
|
+
.split('\n')
|
|
84
|
+
.map((l) => l.replace(/^#+\s*/, '').replace(/\*\*/g, '').trim())
|
|
85
|
+
.find((l) => l !== '');
|
|
86
|
+
if (!first) return `第 ${node?.round ?? 1} 轮`;
|
|
87
|
+
const short = first.length > 24 ? `${first.slice(0, 24)}…` : first;
|
|
88
|
+
return `第 ${node.round} 轮 · ${short}`;
|
|
89
|
+
}
|
|
@@ -1053,6 +1053,12 @@
|
|
|
1053
1053
|
background:var(--paper,#fff);border:1px solid var(--line,#E4E2DC);border-radius:10px;
|
|
1054
1054
|
box-shadow:0 8px 24px rgba(20,22,26,.12);padding:8px 0;
|
|
1055
1055
|
}
|
|
1056
|
+
|
|
1057
|
+
/* 任务页的 ack 定位轨(任务 #48):复用 .doc-rail 视觉,位置贴任务正文区
|
|
1058
|
+
—— 任务页顶部有 mdw-top + mdw-toolbar 两条,轨往下挪,别压住工具栏。
|
|
1059
|
+
.mdw 本身是 position:relative,absolute 轨锚在它上面。 */
|
|
1060
|
+
.mdw > .ack-rail{top:96px;bottom:44px}
|
|
1061
|
+
.mdw > .ack-rail .doc-rail-ticks{gap:12px}
|
|
1056
1062
|
.doc-rail-sec{padding:4px 0 8px;border-bottom:1px solid var(--line,#E4E2DC)}
|
|
1057
1063
|
.doc-rail-sec:last-child{border-bottom:0;padding-bottom:4px}
|
|
1058
1064
|
.doc-rail-sec-lab{
|
|
@@ -14,8 +14,10 @@ import { useToast } from '../../components/toast/index.jsx';
|
|
|
14
14
|
import { useDialog } from '../../components/popup/index.jsx';
|
|
15
15
|
import { GLOBAL_KEY } from '../../modules/board/board-view.js';
|
|
16
16
|
import { buildReopenText } from '../../modules/board/reopen-text.js';
|
|
17
|
+
import { extractAckNodes } from '../../modules/board/ack-nodes.js';
|
|
17
18
|
import ToolBar from '../../components/md/tool-bar.jsx';
|
|
18
19
|
import { MdPanes } from '../../components/md/md-panes.jsx';
|
|
20
|
+
import AckRail from '../../components/md/ack-rail.jsx';
|
|
19
21
|
import AttachBar from '../../components/md/attach-bar.jsx';
|
|
20
22
|
import SelMenu from '../../components/md/sel-menu.jsx';
|
|
21
23
|
import ShortcutSheet from '../../components/md/shortcut-sheet.jsx';
|
|
@@ -288,6 +290,24 @@ export default function PagePanel({ dirKey, task, beside = false, tabId, model }
|
|
|
288
290
|
[taskRow?.roundSummary]
|
|
289
291
|
);
|
|
290
292
|
|
|
293
|
+
// ack 定位轨(任务 #48):反复 ack 反馈的长任务页,按轮次快速跳转。
|
|
294
|
+
// 轮次从正文里抽(每轮一个「## 上一轮完成总结」段)——正文即历史,正文变了轨也跟着变。
|
|
295
|
+
const ackNodes = useMemo(() => (doc ? extractAckNodes(doc.body) : []), [doc && doc.body]);
|
|
296
|
+
// 滚动容器:实时预览是 .mdw-lp(可滚),阅读态是 .mdw-preview。
|
|
297
|
+
// ⚠️ articleRef 指的是 **.mdw-lp-sheet**(live)/ **article**(阅读),两者都是
|
|
298
|
+
// 滚动容器的**子节点**——要往上找(closest),不是往下查(querySelector)。
|
|
299
|
+
// 两种态的 DOM 形状不同,轨内部按态分别定位(见 ack-rail.jsx 的 blockAt)。
|
|
300
|
+
const [ackScrollRoot, setAckScrollRoot] = useState(null);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const root = articleRef.current;
|
|
303
|
+
if (!root) return undefined;
|
|
304
|
+
const pick = () => root.closest('.mdw-lp') || root.closest('.mdw-preview') || null;
|
|
305
|
+
setAckScrollRoot(pick());
|
|
306
|
+
const mo = typeof MutationObserver !== 'undefined' ? new MutationObserver(() => setAckScrollRoot(pick())) : null;
|
|
307
|
+
mo?.observe(root, { childList: true, subtree: true });
|
|
308
|
+
return () => mo?.disconnect();
|
|
309
|
+
}, [doc && doc.mode]);
|
|
310
|
+
|
|
291
311
|
useEffect(() => {
|
|
292
312
|
setSelMenu(null);
|
|
293
313
|
lastSel.current = '';
|
|
@@ -961,6 +981,10 @@ export default function PagePanel({ dirKey, task, beside = false, tabId, model }
|
|
|
961
981
|
)}
|
|
962
982
|
</div>
|
|
963
983
|
|
|
984
|
+
{/* ack 定位轨(任务 #48):反复 ack 反馈的长任务页,一轮一个节点,点击跳过去。
|
|
985
|
+
只有真的有多轮时才有内容(ackNodes 为空 → 组件自身返回 null)。 */}
|
|
986
|
+
<AckRail nodes={ackNodes} scrollRoot={ackScrollRoot} />
|
|
987
|
+
|
|
964
988
|
<SelMenu
|
|
965
989
|
at={selMenu}
|
|
966
990
|
onFormat={(op) => {
|