@yss123/cage-cli 0.3.0 → 0.3.1

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 (107) hide show
  1. package/dist/approval-tree.d.ts +88 -0
  2. package/dist/approval-tree.js +18 -0
  3. package/dist/approval-tree.js.map +1 -0
  4. package/dist/capabilities.js +16 -1
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/graph-approvals.d.ts +11 -1
  7. package/dist/graph-approvals.js +18 -5
  8. package/dist/graph-approvals.js.map +1 -1
  9. package/dist/graph-relations.js +10 -8
  10. package/dist/graph-relations.js.map +1 -1
  11. package/dist/graph.js +2 -0
  12. package/dist/graph.js.map +1 -1
  13. package/dist/ontology.js +8 -5
  14. package/dist/ontology.js.map +1 -1
  15. package/dist/sync.js +1 -1
  16. package/graph-ui/approval-flow.js +28 -8
  17. package/graph-ui/approval-parallel.js +110 -0
  18. package/graph-ui/approval-tree.js +17 -0
  19. package/graph-ui/business.css +26 -0
  20. package/graph-ui/business.js +30 -13
  21. package/graph-ui/graph-approvals.js +18 -5
  22. package/graph-ui/graph-relations.js +10 -8
  23. package/package.json +6 -6
  24. package/templates/app/AGENTS.md +2 -2
  25. package/templates/app/README.md +5 -4
  26. package/templates/app/apps/api/package.json +2 -2
  27. package/templates/app/apps/web/package.json +1 -1
  28. package/templates/app/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
  29. package/templates/app/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
  30. package/templates/app/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
  31. package/templates/app/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
  32. package/templates/app/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
  33. package/templates/app/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
  34. package/templates/app/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
  35. package/templates/app/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
  36. package/templates/app/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
  37. package/templates/app/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
  38. package/templates/app/apps/web/src/shared/lib/error-message.ts +6 -1
  39. package/templates/app/apps/web/src/shared/ui/approval-member-picker.css +10 -0
  40. package/templates/app/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
  41. package/templates/app/cage.config.json +14 -0
  42. package/templates/app/db/migrations/0004_draft.sql +27 -0
  43. package/templates/app/docs/ai/README.md +7 -2
  44. package/templates/app/docs/ai/approvals.md +260 -0
  45. package/templates/app/docs/ai/primitives.md +1 -1
  46. package/templates/app/docs/ai/purchase-example.md +19 -5
  47. package/templates/app/docs/ai/recipes.md +1 -1
  48. package/templates/app/docs/errors/CAGE-AI-001.md +2 -2
  49. package/templates/app/docs/errors/CAGE-APPR-001.md +12 -9
  50. package/templates/app/docs/errors/CAGE-APPR-005.md +4 -4
  51. package/templates/app/docs/errors/CAGE-APPR-006.md +24 -9
  52. package/templates/app/docs/errors/CAGE-APPR-007.md +8 -6
  53. package/templates/app/docs/errors/CAGE-EVENT-001.md +1 -1
  54. package/templates/app/docs/errors/CAGE-REG-001.md +1 -1
  55. package/templates/app/docs/errors/README.md +2 -2
  56. package/templates/app/package.json +3 -3
  57. package/templates/app/packages/contracts/package.json +1 -1
  58. package/templates/app/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
  59. package/templates/app/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
  60. package/templates/app/packages/contracts/src/index.ts +9 -0
  61. package/templates/app/packages/domain/package.json +5 -5
  62. package/templates/app/packages/domain/src/modules/purchase/approval-members.ts +54 -0
  63. package/templates/app/packages/domain/src/modules/purchase/approvals.ts +19 -8
  64. package/templates/app/packages/domain/src/modules/purchase/index.ts +2 -1
  65. package/templates/app/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
  66. package/templates/app/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
  67. package/templates/blank/AGENTS.md +2 -2
  68. package/templates/blank/apps/api/package.json +1 -1
  69. package/templates/blank/apps/web/package.json +1 -1
  70. package/templates/blank/docs/ai/README.md +7 -2
  71. package/templates/blank/docs/ai/approvals.md +260 -0
  72. package/templates/blank/docs/ai/primitives.md +1 -1
  73. package/templates/blank/docs/ai/purchase-example.md +19 -5
  74. package/templates/blank/docs/ai/recipes.md +1 -1
  75. package/templates/blank/docs/errors/CAGE-AI-001.md +2 -2
  76. package/templates/blank/docs/errors/CAGE-APPR-001.md +12 -9
  77. package/templates/blank/docs/errors/CAGE-APPR-005.md +4 -4
  78. package/templates/blank/docs/errors/CAGE-APPR-006.md +24 -9
  79. package/templates/blank/docs/errors/CAGE-APPR-007.md +8 -6
  80. package/templates/blank/docs/errors/CAGE-EVENT-001.md +1 -1
  81. package/templates/blank/docs/errors/CAGE-REG-001.md +1 -1
  82. package/templates/blank/docs/errors/README.md +2 -2
  83. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/components/task-operation-dialog.tsx +61 -0
  84. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/hooks/use-approval-tasks.ts +6 -9
  85. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks-page.tsx +81 -49
  86. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/approval/pages/approval-tasks.css +18 -0
  87. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/approval-history.tsx +39 -4
  88. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/order-reviewers.tsx +38 -0
  89. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/components/purchase-order-form.tsx +9 -2
  90. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-intelligence.css +2 -0
  91. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-order-detail-page.tsx +2 -0
  92. package/templates/blank/docs/examples/purchase-admin/apps/web/src/modules/purchase/pages/purchase-orders-page.tsx +2 -0
  93. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/lib/error-message.ts +6 -1
  94. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.css +10 -0
  95. package/templates/blank/docs/examples/purchase-admin/apps/web/src/shared/ui/approval-member-picker.tsx +51 -0
  96. package/templates/blank/docs/examples/purchase-admin/cage.config.json +14 -0
  97. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/approval-member/index.ts +70 -0
  98. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/aggregates/purchase-order/index.ts +10 -2
  99. package/templates/blank/docs/examples/purchase-admin/packages/contracts/src/index.ts +9 -0
  100. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approval-members.ts +54 -0
  101. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/approvals.ts +19 -8
  102. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/index.ts +2 -1
  103. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/intelligence.test.ts +169 -7
  104. package/templates/blank/docs/examples/purchase-admin/packages/domain/src/modules/purchase/purchase-order.ts +56 -5
  105. package/templates/blank/package.json +3 -3
  106. package/templates/blank/packages/contracts/package.json +1 -1
  107. package/templates/blank/packages/domain/package.json +5 -5
@@ -1,7 +1,8 @@
1
+ import { layoutStructuredApproval } from './approval-parallel.js';
1
2
  /**
2
3
  * 按中英文近似字宽换行,返回完整文字行;不是浏览器字体精确测量。
3
4
  */
4
- function wrap(text, limit) {
5
+ export function wrapApprovalText(text, limit) {
5
6
  const lines = [];
6
7
  let line = '', length = 0;
7
8
  for (const char of text) {
@@ -18,6 +19,14 @@ function wrap(text, limit) {
18
19
  lines.push(line);
19
20
  return lines;
20
21
  }
22
+ /** 结构化条件或并行需要递归布局;只有原始平面顺序组时沿用既有紧凑图。 */
23
+ export function hasStructuredApproval(groups) {
24
+ return groups.some(group => !!group.parallel || !!group.groups);
25
+ }
26
+ /** 标题只在存在真实 AND 容器时说明同时启动,嵌套 when 本身仍是条件顺序而非并行。 */
27
+ function hasParallel(groups) {
28
+ return groups.some(group => !!group.parallel || (group.groups && hasParallel(group.groups)));
29
+ }
21
30
  /** 审批始终在容器内纵向阅读,外部主流程可以横排。先测量再参与全图布局,
22
31
  * 条件与审批步骤都不会越出容器,跳过线在侧边汇合后才进入下一条条件。 */
23
32
  /**
@@ -27,7 +36,9 @@ function wrap(text, limit) {
27
36
  * @returns 相对坐标、文本行与连线;不读取具体审批任务。
28
37
  */
29
38
  export function layoutApprovalFlow(flow, name) {
30
- const title = wrap(name, 18);
39
+ if (hasStructuredApproval(flow.groups))
40
+ return layoutStructuredApproval(flow, name);
41
+ const title = wrapApprovalText(name, 18);
31
42
  const boxes = [];
32
43
  const paths = [];
33
44
  const merges = [];
@@ -45,7 +56,7 @@ export function layoutApprovalFlow(flow, name) {
45
56
  * 测量任务标题、选人与规则,推进纵向游标并连接前驱;条件组首任务可标记“是”分支。
46
57
  */
47
58
  const addTask = (task, firstLabel) => {
48
- const title = wrap(task.title, 14), actor = wrap(`审批人:${task.actor}`, 19), rule = wrap(task.rule, 18);
59
+ const title = wrapApprovalText(task.title, 14), actor = wrapApprovalText(`审批人:${task.actor}`, 19), rule = wrapApprovalText(task.rule, 18);
49
60
  const height = 18 + title.length * 18 + actor.length * 16 + rule.length * 16;
50
61
  connect(y, firstLabel);
51
62
  boxes.push({ kind: 'task', x: left, y, width, height, title, actor, rule, task });
@@ -57,7 +68,7 @@ export function layoutApprovalFlow(flow, name) {
57
68
  group.tasks.forEach((task) => addTask(task));
58
69
  continue;
59
70
  }
60
- const title = wrap(`${group.condition}?`, 13);
71
+ const title = wrapApprovalText(`${group.condition}?`, 13);
61
72
  const height = Math.max(64, title.length * 36);
62
73
  connect(y);
63
74
  const top = y;
@@ -101,9 +112,11 @@ export function renderApprovalFlow(layout) {
101
112
  layout.title.forEach((line, index) => content.append(text(24, 31 + index * 20, line, 'approval-flow-title', 'start')));
102
113
  content.append(text(24, 32 + layout.title.length * 20, !layout.flow.route
103
114
  ? '审批步骤待补充 · 点击查看声明'
104
- : layout.flow.groups.some((group) => group.condition)
105
- ? '按顺序审批 · 条件满足时加审'
106
- : '按顺序审批 · 每一步通过后继续', 'approval-flow-subtitle', 'start'));
115
+ : hasParallel(layout.flow.groups)
116
+ ? '分支同时启动 · 全部分支完成后汇合'
117
+ : layout.flow.groups.some((group) => group.condition)
118
+ ? '按顺序审批 · 条件满足时加审'
119
+ : '按顺序审批 · 每一步通过后继续', 'approval-flow-subtitle', 'start'));
107
120
  for (const path of layout.paths) {
108
121
  content.append(vector('path', {
109
122
  d: path.d,
@@ -132,7 +145,7 @@ export function renderApprovalFlow(layout) {
132
145
  }));
133
146
  box.title.forEach((line, index) => item.append(text(box.x + box.width / 2, box.y + box.height / 2 + 4 + (index - (box.title.length - 1) / 2) * 16, line, 'approval-condition-title')));
134
147
  }
135
- else {
148
+ else if (box.kind === 'task') {
136
149
  item.append(vector('rect', {
137
150
  x: box.x,
138
151
  y: box.y,
@@ -148,6 +161,13 @@ export function renderApprovalFlow(layout) {
148
161
  box.actor.forEach((line, index) => item.append(text(center, box.y + 24 + box.title.length * 18 + index * 16, line, 'approval-task-actor')));
149
162
  box.rule.forEach((line, index) => item.append(text(center, box.y + 24 + box.title.length * 18 + box.actor.length * 16 + index * 16, line, 'approval-task-rule')));
150
163
  }
164
+ else {
165
+ // 网关与分支标题不冒充可审批任务,也不显示全局序号;AND 的两条入口/出口由真实布局路径表达。
166
+ const gateway = box.kind === 'fork' || box.kind === 'join';
167
+ item.append(vector('rect', { x: box.x, y: box.y, width: box.width, height: box.height,
168
+ rx: gateway ? 3 : 8, class: gateway ? 'approval-gateway-shape' : 'approval-branch-shape' }));
169
+ box.title.forEach((line, index) => item.append(text(box.x + box.width / 2, box.y + (box.height - box.title.length * 17) / 2 + 13 + index * 17, line, gateway ? 'approval-gateway-title' : 'approval-task-title')));
170
+ }
151
171
  content.append(item);
152
172
  }
153
173
  for (const merge of layout.merges)
@@ -0,0 +1,110 @@
1
+ import { wrapApprovalText } from './approval-flow.js';
2
+ /** 创建空容器;空条件分支仍有一条可连接直线,不假造审批节点或已经运行的票。 */
3
+ function fragment(width = 348, height = 0) {
4
+ return { width, height, axis: width / 2, boxes: [], paths: [], merges: [] };
5
+ }
6
+ /**
7
+ * 把已测量子图复制平移到父图,路径坐标只由本模块产生的 M/L/H/V 绝对指令转换。
8
+ * 不改源片段,递归兄弟分支共享文本对象也不会互相污染;标题与输入不拼入 SVG path。
9
+ */
10
+ function append(parent, child, dx, dy) {
11
+ parent.boxes.push(...child.boxes.map(box => ({ ...box, x: box.x + dx, y: box.y + dy })));
12
+ for (const path of child.paths)
13
+ parent.paths.push({ ...path,
14
+ d: path.d.replace(/([ML])(-?[\d.]+),(-?[\d.]+)|([HV])(-?[\d.]+)/g, (_match, point, x, y, axis, value) => point ? `${point}${Number(x) + dx},${Number(y) + dy}` : `${axis}${Number(value) + (axis === 'H' ? dx : dy)}`),
15
+ ...(path.x === undefined ? {} : { x: path.x + dx }), ...(path.y === undefined ? {} : { y: path.y + dy }) });
16
+ parent.merges.push(...child.merges.map(point => ({ x: point.x + dx, y: point.y + dy })));
17
+ }
18
+ /** 把一个审批叶节点测量成普通任务框;编号只是声明定位,不决定并行分支的实际运行顺序。 */
19
+ function taskFragment(task) {
20
+ const title = wrapApprovalText(task.title, 14), actor = wrapApprovalText(`审批人:${task.actor}`, 19), rule = wrapApprovalText(task.rule, 18);
21
+ const height = 18 + title.length * 18 + actor.length * 16 + rule.length * 16, result = fragment(348, height);
22
+ result.boxes.push({ kind: 'task', x: 50, y: 0, width: 248, height, title, actor, rule, task });
23
+ return result;
24
+ }
25
+ /** 各顺序子片段在最大宽度中心对齐,连接只经过相邻片段之间的空隙。 */
26
+ function sequence(parts) {
27
+ // 条件旁路使子图左右不对称,按中心轴两侧的最大占用测量,不能只取原始 width 后居中导致右侧越界。
28
+ const result = fragment(Math.max(348, ...parts.map(part => 2 * Math.max(part.axis, part.width - part.axis))));
29
+ if (!parts.length) {
30
+ result.height = 20;
31
+ result.paths.push({ d: `M${result.axis},0 V20` });
32
+ return result;
33
+ }
34
+ for (const [index, part] of parts.entries()) {
35
+ if (index) {
36
+ result.paths.push({ d: `M${result.axis},${result.height} V${result.height + 28}` });
37
+ result.height += 28;
38
+ }
39
+ append(result, part, result.axis - part.axis, result.height);
40
+ result.height += part.height;
41
+ }
42
+ return result;
43
+ }
44
+ /**
45
+ * 条件未满足的路径在整个子树外侧绕行并汇合,包含并行网关时也不能跳过一半后进入内部支路。
46
+ * 中心轴保持子树入口,额外宽度只用于右侧旁路;祖先会按 axis 平移,避免嵌套时斜穿节点。
47
+ */
48
+ function conditional(body, condition) {
49
+ const result = fragment(body.width + 76), title = wrapApprovalText(`${condition}?`, 13), height = Math.max(64, title.length * 36);
50
+ result.axis = body.axis;
51
+ result.boxes.push({ kind: 'condition', x: result.axis - 124, y: 0, width: 248, height, title, actor: [], rule: [] });
52
+ const top = height + 30, merge = top + body.height + 26;
53
+ append(result, body, 0, top);
54
+ result.paths.push({ d: `M${result.axis},${height} V${top}`, label: '是', x: result.axis + 16, y: height + 18 }, { d: `M${result.axis},${top + body.height} V${merge}` }, { d: `M${result.axis + 124},${height / 2} H${body.width + 38} V${merge} H${result.axis}`,
55
+ label: '否 · 跳过', x: body.width + 38, y: (height / 2 + merge) / 2, skip: true });
56
+ result.merges.push({ x: result.axis, y: merge });
57
+ result.height = merge;
58
+ return result;
59
+ }
60
+ /**
61
+ * 并行分支分别测量,入口共享同一 fork,出口统一接 join;短分支等待线停留在自己通道内。
62
+ * 分支内部可以有条件或嵌套并行,所有子树完成后才绘制后续出口,不把任一分支画成可独自通过。
63
+ */
64
+ function parallelFragment(parallel) {
65
+ const branches = parallel.branches.map(branch => ({ title: wrapApprovalText(branch.title, 14), body: groupsFragment(branch.groups) }));
66
+ const gap = 30, width = branches.reduce((sum, branch) => sum + branch.body.width, 0) + gap * (branches.length - 1);
67
+ const result = fragment(Math.max(348, width)), title = wrapApprovalText(`${parallel.title} · AND 分叉`, 24), forkHeight = 20 + title.length * 17;
68
+ result.boxes.push({ kind: 'fork', x: result.axis - 150, y: 0, width: 300, height: forkHeight, title, actor: [], rule: [] });
69
+ const splitY = forkHeight + 20, branchTop = splitY + 20;
70
+ let dx = 0, bottom = branchTop;
71
+ const ends = [];
72
+ for (const branch of branches) {
73
+ const axis = dx + branch.body.axis, labelHeight = 16 + branch.title.length * 17;
74
+ result.boxes.push({ kind: 'branch', x: axis - 124, y: branchTop, width: 248, height: labelHeight,
75
+ title: branch.title, actor: [], rule: [] });
76
+ const bodyTop = branchTop + labelHeight + 24;
77
+ result.paths.push({ d: `M${result.axis},${forkHeight} V${splitY} H${axis} V${branchTop}` }, { d: `M${axis},${branchTop + labelHeight} V${bodyTop}` });
78
+ append(result, branch.body, dx, bodyTop);
79
+ const end = bodyTop + branch.body.height;
80
+ ends.push({ x: axis, y: end });
81
+ bottom = Math.max(bottom, end);
82
+ dx += branch.body.width + gap;
83
+ }
84
+ const mergeY = bottom + 24, joinY = mergeY + 20, joinHeight = 40;
85
+ for (const end of ends)
86
+ result.paths.push({ d: `M${end.x},${end.y} V${mergeY} H${result.axis} V${joinY}` });
87
+ result.boxes.push({ kind: 'join', x: result.axis - 150, y: joinY, width: 300, height: joinHeight,
88
+ title: ['AND 汇合 · 等待全部活动分支'], actor: [], rule: [] });
89
+ result.height = joinY + joinHeight;
90
+ return result;
91
+ }
92
+ /** 将保留的声明组递归转换为几何,条件包裹整个子树,parallel 和普通 tasks 不混为串行。 */
93
+ function groupsFragment(groups) {
94
+ return sequence(groups.map(group => {
95
+ const body = group.parallel ? parallelFragment(group.parallel) : group.groups ? groupsFragment(group.groups) : sequence(group.tasks.map(taskFragment));
96
+ return group.condition ? conditional(body, group.condition) : body;
97
+ }));
98
+ }
99
+ /**
100
+ * 为带嵌套容器的审批投影生成完整布局;外层主流程使用返回宽高避免与邻接业务节点重叠。
101
+ * 结构输入由已校验声明派生;此处不重新执行条件,也不猜测当前实例哪些分支已跳过。
102
+ */
103
+ export function layoutStructuredApproval(flow, name) {
104
+ const title = wrapApprovalText(name, 18), body = groupsFragment(flow.groups), top = 64 + title.length * 20;
105
+ const framed = fragment(body.width + 52);
106
+ append(framed, body, 26, top);
107
+ const footer = top + body.height + 42;
108
+ framed.paths.push({ d: `M${26 + body.axis},${top + body.height} V${footer - 16}` });
109
+ return { flow, title, width: framed.width, height: footer + 60, boxes: framed.boxes, paths: framed.paths, merges: framed.merges, footer };
110
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 审批声明 JSON 的只读结构与遍历工具,供本体抽取、字段关系说明和静态流程图共同消费。
3
+ * 仅遍历 when、parallel 和 branch 保留的声明树,不执行条件、选人、审批引擎或实际实例状态。
4
+ * 浏览器可引用这些类型和纯函数,不引入 approval 运行时;标题和依赖始终来自抽取快照。
5
+ */
6
+ /**
7
+ * 按声明的深度优先次序遍历全部节点,保留容器供条件解释使用,分支本身不是可执行步骤。
8
+ * 本函数不排序或改写源树;声明深度/数量由注册器限制,序号只能作为静态定位,不能当执行先后。
9
+ */
10
+ export function approvalRouteNodes(nodes) {
11
+ return nodes.flatMap(node => [node, ...(node.kind === 'when' ? approvalRouteNodes(node.steps) :
12
+ node.kind === 'parallel' ? node.branches.flatMap(branch => approvalRouteNodes(branch.nodes)) : [])]);
13
+ }
14
+ /** 收集全部人工/AI 叶节点,用于取证与选人依赖;并行结构仍需由调用方从原树单独保留。 */
15
+ export function approvalRouteSteps(nodes) {
16
+ return approvalRouteNodes(nodes).filter((node) => node.kind === 'step' || node.kind === 'ai');
17
+ }
@@ -553,6 +553,22 @@
553
553
  font-size: 12px;
554
554
  font-weight: 500;
555
555
  }
556
+ /* AND 网关与分支标题使用独立形状,不能被误认成一个需要审批的任务;坐标由递归测量布局提供。 */
557
+ .approval-gateway-shape {
558
+ fill: #e9f0e4;
559
+ stroke: #72916f;
560
+ stroke-width: 1.5;
561
+ }
562
+ .approval-gateway-title {
563
+ fill: #35573b;
564
+ font-size: 12px;
565
+ font-weight: 600;
566
+ }
567
+ .approval-branch-shape {
568
+ fill: #f2f5ed;
569
+ stroke: #b5c5ab;
570
+ stroke-dasharray: 4 3;
571
+ }
556
572
  .approval-flow-merge {
557
573
  fill: #8ba28e;
558
574
  }
@@ -781,6 +797,16 @@
781
797
  .business-approval-steps p {
782
798
  font-size: 10px;
783
799
  }
800
+ /* 并行分支在规则详情中保留树层级,独立左边线区分分支,不靠列表编号暗示执行先后。 */
801
+ .business-approval-branches {
802
+ margin: 8px 0;
803
+ padding-left: 16px;
804
+ list-style: none;
805
+ border-left: 2px solid #b5c5ab;
806
+ }
807
+ .business-approval-branches > li {
808
+ margin: 10px 0 14px;
809
+ }
784
810
  /* 源码预览按行显示并保留原始缩进,横向溢出在代码框内滚动;行号只参与显示。 */
785
811
  .source-preview {
786
812
  min-width: 0;
@@ -496,20 +496,37 @@ export function renderBusiness(host, model, snapshot, actions) {
496
496
  }
497
497
  const approvalFlow = approvals.get(selected.id);
498
498
  if (approvalFlow) {
499
- const rules = section('审批步骤与条件', '按下列顺序执行;条件不满足时跳过该组审批。');
500
- const list = h('ol', 'business-approval-steps');
501
- for (const group of approvalFlow.groups) {
502
- const row = h('li');
503
- if (group.condition)
504
- row.append(h('strong', '', `当${group.condition}时`));
505
- for (const task of group.tasks) {
506
- row.append(h('strong', '', `${task.number}. ${task.title}`));
507
- row.append(h('p', '', `审批人:${task.actor} · ${task.rule}`));
499
+ const rules = section('审批步骤与条件', '顺序节点依次执行;并行分支同时开始,全部活动分支完成后汇合。条件在启动时判断。');
500
+ /** 阅读说明与图保持同一嵌套结构,编号只作定位,不把兄弟分支写成前后执行。 */
501
+ const describeGroups = (groups) => {
502
+ const list = h('ol', 'business-approval-steps');
503
+ for (const group of groups) {
504
+ const row = h('li');
505
+ if (group.condition)
506
+ row.append(h('strong', '', `当${group.condition}时`));
507
+ for (const task of group.tasks) {
508
+ row.append(h('strong', '', `${task.number}. ${task.title}`));
509
+ row.append(h('p', '', `审批人:${task.actor} · ${task.rule}`));
510
+ }
511
+ if (group.groups)
512
+ row.append(describeGroups(group.groups));
513
+ if (group.parallel) {
514
+ row.append(h('strong', '', `${group.parallel.title} · AND 并行`));
515
+ const branches = h('ul', 'business-approval-branches');
516
+ for (const branch of group.parallel.branches) {
517
+ const branchRow = h('li');
518
+ branchRow.append(h('strong', '', branch.title), describeGroups(branch.groups));
519
+ branches.append(branchRow);
520
+ }
521
+ row.append(branches, h('p', 'source-note', '各分支同时开始;全部活动分支完成 → AND 汇合后继续'));
522
+ }
523
+ if (group.condition)
524
+ row.append(h('p', 'source-note', '条件不满足 → 跳过整组,继续后续步骤'));
525
+ list.append(row);
508
526
  }
509
- if (group.condition)
510
- row.append(h('p', 'source-note', '条件不满足 → 跳过,继续后续步骤'));
511
- list.append(row);
512
- }
527
+ return list;
528
+ };
529
+ const list = describeGroups(approvalFlow.groups);
513
530
  rules.append(list, h('p', '', approvalFlow.route
514
531
  ? '所需步骤全部通过后继续业务;任一步骤驳回则结束本轮审批。'
515
532
  : '未抽取到绑定的审批步骤,请查看审批声明或外部流程配置。'));
@@ -50,16 +50,29 @@ export function approvalFlowForStep(model, step, labels = {}) {
50
50
  ? `${fieldName(item.approver.field)}的负责人`
51
51
  : item.approver.kind === 'role'
52
52
  ? (labels[`role:${item.approver.code}`] ?? '待命名审批角色')
53
- : `指定用户 ${item.approver.userId}`,
54
- rule: item.mode === 'all' ? '所有审批人同意后继续' : '任一审批人同意即可继续',
53
+ : item.approver.kind === 'field' ? `${fieldName(item.approver.field)}指定的审批人` : `指定用户 ${item.approver.userId}`,
54
+ rule: [item.mode === 'all' ? '所有审批人同意后继续' : item.actions?.addSign ? '任一原审批人同意' : '任一审批人同意即可继续',
55
+ ...(item.assignment === 'candidate' ? ['候选共享一项任务,领取后处理'] : []),
56
+ ...(item.actions?.addSign ? ['支持前签、后签和并行加签,全部加签人同意后继续'] : []),
57
+ ...(item.actions?.transfer ? ['允许转办'] : []), ...(item.actions?.delegate ? ['允许委派,归还后由原负责人决定'] : []),
58
+ ...(item.sla ? [`节点总时限 ${item.sla.durationMinutes} 自然分钟${item.sla.remindBeforeMinutes ? `,提前 ${item.sla.remindBeforeMinutes} 分钟提醒` : ''};超时仅提醒`] : [])].join(';'),
55
59
  mode: item.mode,
56
60
  });
61
+ /** 条件内只有普通叶步骤时保留原协议;含嵌套条件/并行时保留树,避免旧图把分支误连成一条链。 */
62
+ const groups = (nodes) => nodes.map(item => {
63
+ if (item.kind === 'parallel')
64
+ return { tasks: [], parallel: { title: item.title,
65
+ branches: item.branches.map(branch => ({ title: branch.title, groups: groups(branch.nodes) })) } };
66
+ if (item.kind === 'when')
67
+ return { condition: conditionName(item.cond),
68
+ ...(item.steps.every(node => node.kind === 'step' || node.kind === 'ai') ?
69
+ { tasks: item.steps.map(node => task(node)) } : { tasks: [], groups: groups(item.steps) }) };
70
+ return { tasks: [task(item)] };
71
+ });
57
72
  return {
58
73
  approval,
59
74
  route,
60
- groups: route.attrs.nodes.map((item) => item.kind !== 'when'
61
- ? { tasks: [task(item)] }
62
- : { condition: conditionName(item.cond), tasks: item.steps.map(task) }),
75
+ groups: groups(route.attrs.nodes),
63
76
  };
64
77
  }
65
78
  /** 从真实驳回结论绑定辨认出口,不依赖阶段标题里是否含有“驳回”二字。 */
@@ -4,6 +4,7 @@
4
4
  * 不求值 handler、映射或审批条件;存在连线只代表对应声明关系,不证明某次操作已发生。
5
5
  */
6
6
  import { kindLabels, nodeLabel } from './graph-model.js';
7
+ import { approvalRouteNodes, approvalRouteSteps } from './approval-tree.js';
7
8
  /**
8
9
  * 按真实关系种类解释连线,补充声明来源和证据强度。
9
10
  * @param edge 已投影且两端有效的边。
@@ -76,13 +77,14 @@ export function explainEdge(edge, model, labels = {}) {
76
77
  const as = edge.as ?? '';
77
78
  if (as === '可路由字段')
78
79
  return result('允许审批读取', '审批可用字段', `“${a}”把“${b}”列入 routable,允许该字段作为审批流程变量。`, '明确审批可以拿到哪些业务数据。这里只表示允许使用;是否真的参与分支或审批选人,要查看审批路由的字段关系。', [`${from.id}.routable 包含 ${field}`]);
80
+ if (as === '催办申请人')
81
+ return result('确定催办申请人', '流程运营依赖', `“${a}”以提交时“${b}”的用户标识确定可催办申请人。`, '字段必须进入 routable;催办仍校验当前对象可见性、运行节点、业务版本及冷却,不赋予审批权限。字段也可能被选人或条件使用,完整用途见路由声明。', [`route.reminders.requesterField = ${JSON.stringify(field)}`]);
79
82
  if (as === '路由条件') {
80
- // fieldsUsed 同时包含 when 条件和 deptLeader 选人字段,不能一律解释成条件判断。
83
+ // fieldsUsed 同时包含 when 条件、部门负责人和单据人员字段,必须区分实际选人方式。
81
84
  const nodes = from.attrs.nodes;
82
- const conditions = nodes.filter((n) => n.kind === 'when' && n.cond.field === field);
83
- const steps = nodes
84
- .flatMap((n) => (n.kind !== 'when' ? [n] : n.steps))
85
- .filter((s) => (s.approver ?? s.handoff)?.kind === 'deptLeader' && (s.approver ?? s.handoff)?.field === field);
85
+ const conditions = approvalRouteNodes(nodes).filter((n) => n.kind === 'when' && n.cond.field === field);
86
+ const steps = approvalRouteSteps(nodes).map(step => ({ ...step, selector: step.kind === 'ai' ? step.handoff : step.approver }))
87
+ .filter(step => (step.selector.kind === 'deptLeader' || step.selector.kind === 'field') && step.selector.field === field);
86
88
  const operators = {
87
89
  gt: '大于',
88
90
  gte: '大于等于',
@@ -93,11 +95,11 @@ export function explainEdge(edge, model, labels = {}) {
93
95
  in: '属于',
94
96
  };
95
97
  const reasons = [
96
- ...steps.map((s) => `“${s.title}”步骤根据“${b}”找到对应部门的负责人`),
97
- ...conditions.map((n) => `当“${b}”${operators[n.cond.op]} ${JSON.stringify(n.cond.value)} 时,加入${n.steps.map((s) => `“${s.title}”`).join('、')}步骤`),
98
+ ...steps.map((s) => s.selector.kind === 'field' ? `“${s.title}”步骤读取“${b}”保存的审批人员` : `“${s.title}”步骤根据“${b}”找到对应部门的负责人`),
99
+ ...conditions.map((n) => `当“${b}”${operators[n.cond.op]} ${JSON.stringify(n.cond.value)} 时,加入${approvalRouteSteps(n.steps).map((s) => `“${s.title}”`).join('、')}步骤(内部顺序或并行关系见路由)`),
98
100
  ];
99
101
  return result(steps.length && conditions.length ? '选人和分支依据' : steps.length ? '确定审批人' : '判断审批分支', '审批路由依赖', `${reasons.join(';')}。`, '字段值会影响审批人或需要经过的审批步骤;修改字段含义时,应一起检查这些路由配置。', [
100
- ...steps.map((s) => `${s.title}: approver.deptLeader(${JSON.stringify(field)})`),
102
+ ...steps.map((s) => `${s.title}: approver.${s.selector.kind}(${JSON.stringify(field)})`),
101
103
  ...conditions.map((n) => `when(${n.cond.op}(${JSON.stringify(field)}, ${JSON.stringify(n.cond.value)}))`),
102
104
  ]);
103
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yss123/cage-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "cage 命令行:init、gen、check、graph、context、impact、test、verify、budget、sync、upgrade、dev、migrate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,11 +31,11 @@
31
31
  "typescript": "~5.9.3",
32
32
  "vitest": "^4.1.12",
33
33
  "zod": "^4.1.5",
34
- "@yss123/cage-ontology": "0.3.0",
35
- "@yss123/cage-admin": "0.3.0",
36
- "@yss123/cage-kernel": "0.3.0",
37
- "@yss123/cage-lint": "0.3.0",
38
- "@yss123/cage-testkit": "0.3.0"
34
+ "@yss123/cage-ontology": "0.3.1",
35
+ "@yss123/cage-admin": "0.3.1",
36
+ "@yss123/cage-lint": "0.3.1",
37
+ "@yss123/cage-kernel": "0.3.1",
38
+ "@yss123/cage-testkit": "0.3.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/pg": "^8.15.5"
@@ -10,7 +10,7 @@
10
10
 
11
11
  本节位于托管区之外,cage sync 会保留。下面的托管区提供框架工作方法。
12
12
 
13
- <!-- cage:begin v0.3.0 -->
13
+ <!-- cage:begin v0.3.1 -->
14
14
  ## Cage 业务开发约定(框架托管)
15
15
 
16
16
  ### 先读,再按需求改业务
@@ -85,7 +85,7 @@
85
85
  - 多页面业务必须有独立 URL,不用 useState/tab 模拟整页导航;详情从 URL 取 ID 并独立加载。前端路由集中在 app,页面从模块公开入口接入 Outlet。登录后保留原路径/query/hash,401 回登录,403 展示权限提示;/admin/ 使用普通链接进入独立系统管理。前端路由不替代后端授权。实现、SPA 部署回退和浏览器配方见 docs/ai/routing.md。
86
86
  - 跨聚合写入通过 reaction 调用命令。`tx: 'same'` 共同提交/回滚,默认 outbox 异步执行。资源 acquires/releases 是图规则标签,实际占用/释放仍由命令完成;离开持有状态要检查每条释放路径。
87
87
  - 批量调用同一命令使用 reactionEach,声明 maxItems、唯一排序字段 orderBy、items 和 map;稳定键排序后串行执行。步骤依赖用 order,同事件同事务分组不得混用有序/无序或重复顺序。保持同事务回滚边界,见 docs/ai/primitives.md。
88
- - 审批通过 approval + route 配置,结论是 process 命令;选中审批人不等于已具备能力、范围和额度。第三方 IO 通过连接器,回调验签后调用 external 命令。
88
+ - 审批通过 approval + route 配置,结论是 process 命令;选中审批人不等于已具备能力、范围和额度。parallel(title, ...branch(...)) 声明结构化 AND:分支同时开始,内部顺序独立,全部活动分支完成后汇合;when 支持嵌套并仍按提交快照求值,声明有深度/数量边界,不支持任意跳转。currentSteps 表示当前活动节点集合,兼容字段 currentStep 只是最小活动索引,不能据此隐藏其他分支。人工节点 assignment: 'candidate' 开放候选领取/释放,approver.field 从 routable 字段选人;actions 开放转办、委派归还和前后/并行必签。任务操作与决定携带 revision,waiting 任务只能查看。approvalOperations() 为框架通用时效运行时:step.sla 配置自然分钟节点总时限,route.reminders 配置申请人催办;迁移并运行 worker 后提供持久站内提醒、已读与 Admin 通用页面,不自动批准或授予权限。政策、SDK 和边界见 docs/ai/approvals.md。第三方 IO 通过连接器,回调验签后调用 external 命令。
89
89
  - job 的读取与调用必须列入 reads/calls。业务导入/导出放在需要的业务页面调用通用任务接口,202 只代表任务已接受;worker 必须运行。
90
90
  - 已部署事件只做兼容扩展;新增字段设为 optional,不兼容含义用新事件。已执行/已发布迁移不能改写,结构更新新增迁移。
91
91
  - 不手改生成物,不关闭门禁掩盖错误,不因方便自动授予生产角色新能力。unsafe 必须写明 reason/owner,不能替代普通业务声明。
@@ -59,10 +59,11 @@ pnpm dev
59
59
  1. 管理员在“库存台账”登记 `A-01` 等物料。
60
60
  2. 进入 `/admin/` 创建采购员、经理,分别分配采购命令和审批能力;经理的 `PurchaseOrder.approve` 参数配置 `ceiling`。
61
61
  3. 把经理设为采购员所在部门的负责人。提单人与审批人必须不同。
62
- 4. 采购员新建并提交采购单;worker 建立审批待办。
63
- 5. 经理在“审批待办”通过,内核同事务生成库存锁;worker 调用 fake ERP 并回写单号。
64
- 6. 超过 50,000 元的采购走财务第二步,需另建 code 为 `finance` 的角色并分配审批人。
65
- 7. 取消单据或收到 ERP 收货回调后,通过异步反应释放锁。回调需要 `ERP_WEBHOOK_SECRET` 对原始 JSON 的 HMAC-SHA256 签名。
62
+ 4. 为需要选人的采购参与者授予 `ApprovalMember.read` 与该聚合 `all` 范围,通过 `ApprovalMember.create` 登记审批协作人的公开业务姓名与专业领域;名录不授予实际审批能力。
63
+ 5. 采购员新建采购单,可开启专业复核并选择 1–5 人;草稿使用 `PurchaseOrder.setReviewers` 调整名单,提交时重新校验。worker 先执行 AI 材料预审;未配置模型或模型失败时按声明转人工接管。
64
+ 6. AI 预审/人工接管之后进入经理节点;启用专业复核时,经理后由所选人员全部会签。经理、专业和财务节点开放转办、委派归还和前置/后置/并行加签。
65
+ 7. 超过 50,000 元的采购继续进入财务共享候选池,需另建 code 为 `finance` 的角色并给实际用户配置权限、范围和额度;财务必须先领取,恰好 50,000 元跳过该步骤。
66
+ 8. 全部必签完成后,最终审批命令同事务生成库存锁;worker 调用 fake ERP 并回写单号。取消单据或收到 ERP 收货回调后异步释放锁;回调需要 `ERP_WEBHOOK_SECRET` 对原始 JSON 的 HMAC-SHA256 签名。
66
67
 
67
68
  `Inventory.available` 是演示台账数量。本例验证库存锁生命周期,尚未实现仓库级的并发扣减、实物库存及退货核算。
68
69
 
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@app/domain": "workspace:*",
13
- "@yss123/cage-http": "0.3.0",
14
- "@yss123/cage-connectors": "0.3.0"
13
+ "@yss123/cage-http": "0.3.1",
14
+ "@yss123/cage-connectors": "0.3.1"
15
15
  }
16
16
  }
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "@app/contracts": "workspace:*",
12
12
  "@tanstack/react-query": "^5.87.1",
13
- "@yss123/cage-client": "0.3.0",
13
+ "@yss123/cage-client": "0.3.1",
14
14
  "react": "^19.1.1",
15
15
  "react-dom": "^19.1.1",
16
16
  "react-markdown": "^10.1.0",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 人工待办的转办、委派和三种加签操作表单,由审批收件箱按当前任务快照打开。
3
+ * 选择来自业务协作名录,提交携带任务 revision 和必填原因;权限/同节点重复责任由引擎重查。
4
+ * 失败保留选人和原因;成功刷新真实任务并关闭。等待/受托任务不从页面打开本表单,不在前端改流程状态。
5
+ */
6
+ import { useState, type FormEvent } from 'react';
7
+ import { client } from '@app/contracts';
8
+ import type { ApprovalTask, ApprovalTaskAction } from '@yss123/cage-client';
9
+ import { ApprovalMemberPicker } from '../../../shared/ui/approval-member-picker';
10
+ import { errorMessage } from '../../../shared/lib/error-message';
11
+ import { useQueryClient } from '@tanstack/react-query';
12
+
13
+ /** 页面允许发起的人员管理操作,归还/领取/释放在任务行直接处理。 */
14
+ export type PersonnelOperation = 'transfer' | 'delegate' | 'parallel' | 'before' | 'after';
15
+ /** 业务名称与引擎 placement 对应;不引入额外执行步骤或暗示自动授予权限。 */
16
+ const labels = { transfer: '转办审批', delegate: '委派协助', parallel: '并行加签', before: '前加签', after: '后加签' } as const;
17
+ const descriptions = {
18
+ transfer: '将这项审批责任交给另一位人员,你将不再处理此任务。',
19
+ delegate: '请他人核对材料,受托人归还后仍由你决定。',
20
+ parallel: '你与新增人员可以同时处理,所有追加意见都是必签。',
21
+ before: '先请新增人员签署,全部完成后这项任务才回到你手中。',
22
+ after: '你先签署,随后激活新增人员的必签任务。',
23
+ };
24
+
25
+ /**
26
+ * task 与 operation 在打开时固定;任务变化以服务端冲突响应为准,不自动改用新版本重试。
27
+ * close 返回当前收件箱,成功通过查询缓存刷新;无效输入禁用提交,Esc/取消不产生写入。
28
+ */
29
+ export function TaskOperationDialog({ task, operation, close }: { task: ApprovalTask; operation: PersonnelOperation; close: () => void }) {
30
+ const [userIds, setUserIds] = useState<string[]>([]), [reason, setReason] = useState('');
31
+ const [busy, setBusy] = useState(false), [error, setError] = useState('');
32
+ const cache = useQueryClient();
33
+ const single = operation === 'transfer' || operation === 'delegate';
34
+ /**
35
+ * 将人员选择映射为对应动作;每次携带打开弹窗时的 revision,旧任务冲突时保留输入供用户核对。
36
+ * 只在服务端确认成功后刷新并关闭,不能把选中人员或提交请求本身视为已经完成交接。
37
+ */
38
+ async function submit(event: FormEvent) {
39
+ event.preventDefault();
40
+ if (busy || !userIds.length || !reason.trim()) return;
41
+ setBusy(true); setError('');
42
+ const input: ApprovalTaskAction = single
43
+ ? { action: operation, targetUserId: userIds[0], reason, expectedRevision: task.revision }
44
+ : { action: 'addSign', placement: operation, userIds, reason, expectedRevision: task.revision };
45
+ const result = await client.approvalTaskAction(task.id, input);
46
+ setBusy(false);
47
+ if (!result.ok) { setError(errorMessage(result)); return; }
48
+ await cache.invalidateQueries(); close();
49
+ }
50
+ return <div className="modal-backdrop"><section className="modal approval-operation-dialog" role="dialog" aria-modal="true" aria-labelledby="approval-operation-title"
51
+ onKeyDown={event => { if (event.key === 'Escape' && !busy) close(); }}>
52
+ <div className="section-head"><div><h2 id="approval-operation-title">{labels[operation]}</h2><p className="muted">{task.title}</p></div>
53
+ <button type="button" disabled={busy} onClick={close} aria-label="关闭操作">×</button></div>
54
+ <form onSubmit={submit}><p>{descriptions[operation]}</p>
55
+ <ApprovalMemberPicker value={userIds} onChange={setUserIds} max={single ? 1 : 20} exclude={[task.assigneeId, task.ownerId].filter((id): id is string => !!id)} disabled={busy} />
56
+ <label>操作原因<textarea autoFocus rows={3} maxLength={1000} required value={reason} disabled={busy} onChange={event => setReason(event.target.value)} placeholder="说明业务原因,供单据参与者查看" /></label>
57
+ {error && <p className="error" role="alert">{error}</p>}
58
+ <div className="actions"><button type="button" disabled={busy} onClick={close}>取消</button><button className="primary" disabled={busy || !userIds.length || !reason.trim()}>{busy ? '提交中…' : `确认${labels[operation]}`}</button></div>
59
+ </form>
60
+ </section></div>;
61
+ }
@@ -1,29 +1,26 @@
1
1
  /**
2
- * 当前账号审批待办的共享查询入口,供审批页及 app 的数量组合复用同一缓存键。
2
+ * 当前账号本人、候选或等待加签任务的共享查询入口,默认待办供 app 数量组合复用。
3
3
  * 任务分配与实际决策授权均来自服务端,不从角色名称在前端自行推导。
4
4
  */
5
5
  import { useQuery } from '@tanstack/react-query';
6
6
  import { client } from '@app/contracts';
7
7
  import { errorMessage } from '../../../shared/lib/error-message';
8
8
 
9
- /**
10
- * 待办列表展示所需字段:id 是任务标识,businessKey 是业务记录键,title 为任务标题,createdAt 为创建时间字符串。
11
- */
12
- type Task = { id: string; businessKey: string; title: string; createdAt: string };
13
-
14
9
  /**
15
10
  * 审批是框架通用接口,不属于某个聚合的生成方法,因此请求封装在审批模块内。
16
11
  * 待办页面和导航角标复用同一缓存键,调用方不需要了解接口地址或响应形状。
17
12
  */
18
13
  /**
19
14
  * 在 React Query 中读取审批待办,非 ok 结果转为查询错误。
15
+ * view 默认 pending,与导航角标共享;candidates/waiting 使用独立缓存,不混入可决定的任务数量。
20
16
  * 返回查询状态及 refetch;空数组表示当前结果没有待办,未加载或失败不等同于零条。
21
17
  */
22
- export function useApprovalTasks() {
18
+ export function useApprovalTasks(view: 'pending' | 'candidates' | 'waiting' = 'pending') {
23
19
  return useQuery({
24
- queryKey: ['tasks'],
20
+ queryKey: ['tasks', view],
25
21
  queryFn: async () => {
26
- const result = await client.get<Task[]>('/api/approval/tasks');
22
+ // 使用框架任务协议保留 revision,避免页面决定丢失任务流转后的并发校验。
23
+ const result = await (view === 'candidates' ? client.approvalCandidateTasks() : client.approvalTasks(view));
27
24
  if (!result.ok) throw new Error(errorMessage(result));
28
25
  return result.data;
29
26
  },