dsh-knj-workflow 0.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2058 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-knj-workflow",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ let react = require("react");
9
+ let react_dom_client = require("react-dom/client");
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // dsh-knj-workflow Client 端
13
+ // 任务列表 / 任务详情(横向步骤条 + 阶段重跑/继续)/ 工作流配置
14
+ // 数据通过同源 fetch('/devtask/...') 与 Host HTTP API 通信
15
+ // ---------------------------------------------------------------------------
16
+ const PREFIX = '/devtask';
17
+
18
+ // sessions 服务(apply 时注入):用于「执行记录」把节点 subagent 跳回原生会话查看
19
+ let _sessions = null;
20
+ // workspaces 服务(apply 时注入):用于「新建任务」工作目录下拉
21
+ let _workspaces = null;
22
+ let _ctx = null; // client root ctx(动态取服务,避免 apply 时机问题)
23
+
24
+ async function api(path, opts = {}) {
25
+ const res = await fetch(`${PREFIX}${path}`, {
26
+ credentials: 'same-origin',
27
+ ...opts,
28
+ headers: { 'content-type': 'application/json', ...(opts.headers || {}) },
29
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
30
+ });
31
+ const data = await res.json().catch(() => ({}));
32
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
33
+ return data;
34
+ }
35
+
36
+ const STATUS_META = {
37
+ pending: { label: '待执行', color: 'var(--dsw-static-neutral-bluish-400)' },
38
+ running: { label: '运行中', color: 'var(--dsw-static-blue-600)' },
39
+ success: { label: '成功', color: 'var(--dsw-static-green-500)' },
40
+ failed: { label: '失败', color: 'var(--dsw-static-red-500)' }, cancelled: { label: '已取消', color: 'var(--dsw-static-amber-500)' },
41
+ 'waiting-human': { label: '待人工处理', color: '#7c5cff' },
42
+ };
43
+ /** 人工节点去向:routes 优先(多去向),兼容旧 approveTo/rejectTo(推导为 通过/驳回 两条) */
44
+ function humanRoutes(node) {
45
+ if (Array.isArray(node.routes) && node.routes.length > 0) return node.routes;
46
+ const r = [];
47
+ if (node.approveTo) r.push({ label: '通过', to: node.approveTo, tone: 'success' });
48
+ if (node.rejectTo) r.push({ label: '驳回', to: node.rejectTo, tone: 'danger' });
49
+ return r;
50
+ }
51
+ const ROUTE_TONES = { success: '#16a34a', danger: '#dc2626', warning: '#d97706', info: '#2f6bff', neutral: '#5b6472' };
52
+ function routeColor(route) { return ROUTE_TONES[route.tone] || ROUTE_TONES.info; }
53
+ /** 人工去向是否有缺失(无去向或某去向目标为空)→ 画布警示 */
54
+ function humanRoutesIncomplete(node) {
55
+ const routes = humanRoutes(node);
56
+ if (routes.length === 0) return true;
57
+ return routes.some((r) => !r.to);
58
+ }
59
+ /** 16×16 outline SVG 图标(lucide 风格,与 DSH 原生图标同规格) */
60
+ function icon(kind) {
61
+ if (kind === 'check') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M3.5 8.5l3 3 6-6', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }));
62
+ if (kind === 'clock') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('circle', { cx: '8', cy: '8', r: '5.5', stroke: 'currentColor', strokeWidth: 1.5 }), react.createElement('path', { d: 'M8 5v3l2 1.2', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
63
+ if (kind === 'plus') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M8 3.5v9M3.5 8h9', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
64
+ if (kind === 'x') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M4 4l8 8M12 4l-8 8', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
65
+ if (kind === 'gear') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('circle', { cx: '8', cy: '8', r: '2.2', stroke: 'currentColor', strokeWidth: 1.5 }), react.createElement('path', { d: 'M8 1.5v2.3M8 12.2v2.3M1.5 8h2.3M12.2 8h2.3M3.4 3.4l1.6 1.6M11 11l1.6 1.6M12.6 3.4L11 5M5 11l-1.6 1.6', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
66
+ if (kind === 'list') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('rect', { x: '2.5', y: '2.5', width: '11', height: '11', rx: '2', stroke: 'currentColor', strokeWidth: 1.5 }), react.createElement('path', { d: 'M6 6h.01M6 8.5h.01M6 11h.01M10 6h.01M10 8.5h.01M10 11h.01', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
67
+ if (kind === 'repeat') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M13 8a5 5 0 1 1-1.5-3.5', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }), react.createElement('path', { d: 'M13 1.5V5h-3.5', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }));
68
+ if (kind === 'play') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'currentColor' }, react.createElement('path', { d: 'M5 3.5l7 4.5-7 4.5z' }));
69
+ if (kind === 'refresh') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M13 8a5 5 0 1 1-1.5-3.5', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }), react.createElement('path', { d: 'M13 1.5V5h-3.5', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }));
70
+ if (kind === 'chevron-down') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }));
71
+ if (kind === 'chevron-right') return react.createElement('svg', { viewBox: '0 0 16 16', fill: 'none' }, react.createElement('path', { d: 'M6 4l4 4-4 4', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }));
72
+ return null;
73
+ }
74
+ const STAGE_META = {
75
+ pending: { label: '待执行', color: 'var(--dsw-static-neutral-bluish-300)' },
76
+ running: { label: '运行中', color: 'var(--dsw-static-blue-600)' },
77
+ done: { label: '完成', color: 'var(--dsw-static-green-500)' },
78
+ failed: { label: '失败', color: 'var(--dsw-static-red-500)' },
79
+ skipped: { label: '跳过', color: 'var(--dsw-static-neutral-bluish-400)' },
80
+ };
81
+
82
+ const css = `
83
+ /* dsh-knj-workflow 样式:跟随 DSH 原生设计令牌(--dsw-*)与字号阶梯
84
+ 原生字号体系:标题 16/500 · 正文 14/400(lh22) · 辅助 12/400(lh18)
85
+ 原生按钮:36px 高胶囊 · 输入:10px 圆角 + border-l2 + bg-layer-1 */
86
+ .knj-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-2);z-index:9999;display:flex;align-items:center;justify-content:center}
87
+ .knj-panel{background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;width:min(960px,94vw);max-height:88vh;display:flex;flex-direction:column;box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary)}
88
+ .knj-head{display:flex;align-items:center;gap:12px;padding:14px 20px;border-bottom:1px solid var(--dsw-alias-border-l2)}
89
+ .knj-head h2{margin:0;font-size:16px;font-weight:500;color:var(--dsw-alias-label-primary)}
90
+ .knj-close{margin-left:auto;background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;height:32px;padding:0 14px;cursor:pointer;font-size:14px;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center}
91
+ .knj-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
92
+ .knj-body{padding:20px;overflow:auto;flex:1}
93
+ .knj-tabs{display:flex;gap:6px;margin-bottom:16px}
94
+ .knj-tab{border:1px solid var(--dsw-alias-border-l2);background:transparent;border-radius:999px;height:32px;padding:0 14px;font-size:14px;cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center}
95
+ .knj-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
96
+ .knj-tab.on{background:var(--dsw-alias-button-primary-fill);border-color:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-inverted)}
97
+ .knj-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;margin-bottom:10px;display:flex;align-items:center;gap:12px}
98
+ .knj-card .title{font-weight:500;font-size:14px;color:var(--dsw-alias-label-primary)}
99
+ .knj-card .sub{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);margin-top:2px}
100
+ .knj-badge{font-size:12px;padding:2px 10px;border-radius:999px;color:#fff;font-weight:500;line-height:18px;white-space:nowrap}
101
+ .knj-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:999px;height:32px;padding:0 14px;font-size:14px;cursor:pointer;color:var(--dsw-alias-label-primary);display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:background .15s,color .15s}
102
+ .knj-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
103
+ .knj-btn.primary{background:var(--dsw-alias-button-primary-fill);border-color:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-inverted)}
104
+ .knj-btn.primary:hover{background:var(--dsw-alias-button-primary-hover)}
105
+ .knj-btn.danger{color:var(--dsw-alias-state-error-primary)}
106
+ .knj-btn.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
107
+ .knj-btn:disabled{opacity:.5;cursor:not-allowed}
108
+ .knj-btn svg{flex:none;width:16px;height:16px}
109
+ .knj-progress{height:4px;background:var(--dsw-alias-border-l2);border-radius:999px;overflow:hidden;width:140px}
110
+ .knj-progress>div{height:100%;background:var(--dsw-alias-state-business-primary);transition:width .4s}
111
+ .knj-steps{display:flex;align-items:flex-start;gap:0;padding:10px 4px;overflow-x:auto}
112
+ .knj-step{display:flex;flex-direction:column;align-items:center;flex:0 0 auto;width:110px;position:relative}
113
+ .knj-step .dot{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;z-index:1}
114
+ .knj-step .dot svg{width:14px;height:14px}
115
+ .knj-step .lbl{font-size:12px;line-height:18px;margin-top:6px;text-align:center;color:var(--dsw-alias-label-secondary);max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
116
+ .knj-step .act{margin-top:6px;min-height:26px}
117
+ .knj-step .link{position:absolute;top:14px;left:55px;width:110px;height:2px;background:var(--dsw-alias-border-l2)}
118
+ .knj-step .link.done{background:var(--dsw-alias-state-success-primary)}
119
+ .knj-step:last-child .link{display:none}
120
+ .knj-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;height:36px;padding:0 12px;font-size:14px;width:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary)}
121
+ .knj-input::placeholder{color:var(--dsw-alias-label-tertiary)}
122
+ .knj-input:focus{outline:2px solid var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
123
+ .knj-input:disabled{opacity:.5}
124
+ .knj-row{display:flex;gap:10px;margin-bottom:12px;align-items:center}
125
+ .knj-empty{color:var(--dsw-alias-label-tertiary);text-align:center;padding:30px;font-size:14px}
126
+ .knj-err{background:var(--dsw-alias-state-error-secondary);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);border-radius:10px;padding:10px 14px;margin-bottom:12px;font-size:12px;line-height:18px}
127
+ .knj-textarea{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:8px 12px;font-size:14px;width:100%;box-sizing:border-box;font-family:var(--dsw-font-family);min-height:120px;resize:both;overflow:auto;color:var(--dsw-alias-label-primary);line-height:22px}
128
+ .knj-textarea:focus{outline:2px solid var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
129
+ .knj-textarea.knj-invalid{border-color:var(--dsw-alias-state-error-primary);outline:1px solid var(--dsw-alias-state-error-primary)}
130
+ .knj-stage-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:8px 12px;margin-bottom:8px}
131
+ .knj-stage-row input{flex:1}
132
+ .knj-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-label-primary-inverted);padding:10px 18px;border-radius:999px;font-size:14px;z-index:10001;box-shadow:var(--dsw-shadow-lv2)}
133
+ .knj-prompt-block{margin-bottom:10px}
134
+ .knj-prompt-block .lbl2{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);margin-bottom:4px}
135
+ /* 左侧栏入口按钮:与原生「设置」触发按钮逐属性对齐(.VOzbGW_trigger) */
136
+ .knj-newtask{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary);background:transparent;border:none;border-radius:12px;flex:none;justify-content:flex-start;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden;transition:background .15s}
137
+ .knj-newtask:hover{background:var(--dsw-alias-interactive-bg-hover)}
138
+ .knj-newtask:active{background:var(--dsw-alias-interactive-bg-active)}
139
+ .knj-newtask svg,.knj-newtask .knj-ico{flex:none;width:16px;height:16px;color:var(--dsw-alias-label-primary)}
140
+ .knj-form-section{margin-bottom:12px}
141
+ .knj-form-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary);margin-bottom:6px}
142
+ /* 中央「开发任务」页签:铺满主区 */
143
+ .knj-workbench{height:100%;display:flex;flex-direction:column;padding:16px 20px;overflow:auto;box-sizing:border-box;color:var(--dsw-alias-label-primary)}
144
+ /* 工作台顶部:下划线 tab + 新建按钮 */
145
+ .knj-wb-head{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);margin-bottom:16px;flex:none}
146
+ .knj-wb-tabs{display:flex;gap:4px}
147
+ .knj-wb-tab{background:transparent;border:none;padding:10px 14px;font-size:14px;cursor:pointer;color:var(--dsw-alias-label-secondary);border-bottom:2px solid transparent;margin-bottom:-1px;font-family:inherit;transition:color .15s}
148
+ .knj-wb-tab:hover{color:var(--dsw-alias-label-primary)}
149
+ .knj-wb-tab.on{color:var(--dsw-alias-label-primary);font-weight:600;border-bottom-color:var(--dsw-alias-state-business-primary)}
150
+ /* 任务/工作流卡片 */
151
+ .knj-item-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:14px 16px;margin-bottom:10px;transition:border-color .15s,box-shadow .15s}
152
+ .knj-item-card:hover{border-color:var(--dsw-alias-border-l3);box-shadow:0 2px 8px rgba(16,24,40,.06)}
153
+ .knj-item-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
154
+ .knj-item-title{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
155
+ .knj-item-title:hover{color:var(--dsw-alias-state-business-primary)}
156
+ .knj-item-stage{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);margin-top:6px}
157
+ .knj-item-foot{display:flex;align-items:center;gap:10px;margin-top:10px}
158
+ .knj-item-foot .knj-progress{flex:1;width:auto}
159
+ .knj-item-pct{font-size:12px;color:var(--dsw-alias-label-secondary);min-width:36px;text-align:right;font-variant-numeric:tabular-nums}
160
+ /* 分页器 */
161
+ .knj-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:14px}
162
+ .knj-pager button{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:32px;height:32px;cursor:pointer;color:var(--dsw-alias-label-primary);font-size:16px}
163
+ .knj-pager button:hover{background:var(--dsw-alias-interactive-bg-hover)}
164
+ .knj-pager button:disabled{opacity:.4;cursor:not-allowed}
165
+ .knj-pager .pg{font-size:12px;color:var(--dsw-alias-label-secondary)}
166
+ .knj-workbench .knj-tabs{margin-bottom:14px}
167
+ /* 图编辑器(GraphEditor):三段式画布 */
168
+ .knj-graph-editor{height:100%;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}
169
+ .knj-graph-editor.knj-fullscreen{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-base)}
170
+ .knj-ge-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
171
+ .knj-ge-body{flex:1;display:grid;grid-template-columns:160px 1fr 600px;min-height:0}
172
+ .knj-ge-palette{padding:12px;border-right:1px solid var(--dsw-alias-border-l2);overflow:auto;display:flex;flex-direction:column}
173
+ .knj-ge-canvas{position:relative;overflow:auto;background:linear-gradient(var(--dsw-alias-border-l2) 1px,transparent 1px),linear-gradient(90deg,var(--dsw-alias-border-l2) 1px,transparent 1px);background-size:20px 20px;background-color:var(--dsw-alias-bg-base)}
174
+ .knj-ge-props{border-left:1px solid var(--dsw-alias-border-l2);overflow:auto;padding:12px}
175
+ .knj-ge-resizer{width:5px;cursor:col-resize;background:var(--dsw-alias-border-l2);flex:none;transition:background .15s}
176
+ .knj-ge-resizer:hover{background:var(--dsw-alias-state-business-primary)}
177
+ .knj-ge-props-inner{display:flex;flex-direction:column;gap:8px}
178
+ .knj-ge-svg{display:block}
179
+ /* 任务详情:流程节点卡片(纵向,整合状态/产物/执行记录) */
180
+ .knj-node-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;margin-bottom:8px;overflow:hidden;transition:border-color .15s}
181
+ .knj-node-card:hover{border-color:var(--dsw-alias-border-l3)}
182
+ .knj-node-head{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer}
183
+ .knj-node-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
184
+ .knj-node-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;flex:none;font-size:11px}
185
+ .knj-node-dot svg{width:12px;height:12px}
186
+ .knj-node-title{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary);line-height:18px}
187
+ .knj-node-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:16px}
188
+ .knj-node-body{border-top:1px solid var(--dsw-alias-border-l2);padding:10px 12px;background:var(--dsw-alias-bg-base)}
189
+ .knj-node-sec{font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary);margin:8px 0 4px}
190
+ .knj-node-sec:first-child{margin-top:0}
191
+ .knj-node-body pre{font-size:12px;line-height:1.5;overflow:auto;max-height:200px;margin:0;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px}
192
+ `;
193
+
194
+ function toast(msg) {
195
+ const el = document.createElement('div');
196
+ el.className = 'knj-toast';
197
+ el.textContent = msg;
198
+ document.body.appendChild(el);
199
+ setTimeout(() => el.remove(), 2000);
200
+ }
201
+
202
+ let overlayRoot = null;
203
+ let overlayStyle = null;
204
+ function ensureStyle() {
205
+ if (!overlayStyle) {
206
+ overlayStyle = document.createElement('style');
207
+ overlayStyle.textContent = css;
208
+ document.head.appendChild(overlayStyle);
209
+ }
210
+ }
211
+ function openOverlay(tab) {
212
+ if (overlayRoot) {
213
+ overlayRoot.style.display = 'flex';
214
+ return;
215
+ }
216
+ ensureStyle();
217
+ overlayRoot = document.createElement('div');
218
+ document.body.appendChild(overlayRoot);
219
+ react_dom_client.createRoot(overlayRoot).render(react.createElement(Overlay, { initialTab: tab || 'tasks' }));
220
+ }
221
+
222
+ class Overlay extends react.Component {
223
+ constructor(props) {
224
+ super(props);
225
+ this.state = { tab: props.initialTab || 'tasks', tasks: [], workflows: [], error: null, loading: true };
226
+ }
227
+ componentDidMount() { this.refresh(); this._timer = setInterval(() => this.refresh(), 3000); }
228
+ componentWillUnmount() { clearInterval(this._timer); }
229
+ async refresh() {
230
+ try {
231
+ const [t, w] = await Promise.all([api('/tasks'), api('/workflows')]);
232
+ this.setState({ tasks: t.tasks || [], workflows: w.workflows || [], error: null, loading: false });
233
+ } catch (e) {
234
+ this.setState({ error: e.message, loading: false });
235
+ }
236
+ }
237
+ render() {
238
+ const { tab, tasks, workflows, error } = this.state;
239
+ return react.createElement('div', { className: 'knj-overlay' },
240
+ react.createElement('div', { className: 'knj-panel' },
241
+ react.createElement('div', { className: 'knj-head' },
242
+ react.createElement('h2', null, '开发任务编排'),
243
+ react.createElement('div', { className: 'knj-tabs', style: { margin: 0, marginLeft: 16 } },
244
+ react.createElement('button', { className: 'knj-tab' + (tab === 'tasks' ? ' on' : ''), onClick: () => this.setState({ tab: 'tasks' }) }, '任务'),
245
+ react.createElement('button', { className: 'knj-tab' + (tab === 'workflows' ? ' on' : ''), onClick: () => this.setState({ tab: 'workflows' }) }, '工作流'),
246
+ ),
247
+ react.createElement('button', { className: 'knj-close', onClick: () => { if (overlayRoot) overlayRoot.style.display = 'none'; } }, '关闭'),
248
+ ),
249
+ react.createElement('div', { className: 'knj-body' },
250
+ error ? react.createElement('div', { className: 'knj-err' }, '加载失败:' + error) : null,
251
+ tab === 'tasks'
252
+ ? react.createElement(TasksView, { tasks, workflows, onChanged: () => this.refresh() })
253
+ : react.createElement(WorkflowsView, { workflows, onChanged: () => this.refresh() }),
254
+ ),
255
+ ),
256
+ );
257
+ }
258
+ }
259
+
260
+ class TasksView extends react.Component {
261
+ constructor(props) {
262
+ super(props);
263
+ this.state = { newTitle: '', newWf: '', showNew: false, detail: null, busy: false };
264
+ }
265
+ render() {
266
+ const { tasks, workflows } = this.props;
267
+ const { detail } = this.state;
268
+ if (detail) {
269
+ return react.createElement(TaskDetail, {
270
+ task: detail,
271
+ onBack: () => this.setState({ detail: null }),
272
+ onChanged: async () => { await this.props.onChanged(); this.loadDetail(detail.id); },
273
+ });
274
+ }
275
+ return react.createElement('div', null,
276
+ react.createElement('div', { className: 'knj-row' },
277
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => this.setState({ showNew: !this.state.showNew }) }, icon('plus'), '新建任务'),
278
+ ),
279
+ this.state.showNew ? react.createElement('div', { className: 'knj-card', style: { flexDirection: 'column', alignItems: 'stretch' } },
280
+ react.createElement('div', { className: 'knj-row' },
281
+ react.createElement('input', { className: 'knj-input', placeholder: '任务标题(如:新增文件下载功能)', value: this.state.newTitle, onChange: (e) => this.setState({ newTitle: e.target.value }) }),
282
+ ),
283
+ react.createElement('div', { className: 'knj-row' },
284
+ react.createElement('select', { className: 'knj-input', value: this.state.newWf, onChange: (e) => this.setState({ newWf: e.target.value }) },
285
+ react.createElement('option', { value: '' }, '选择工作流…'),
286
+ (workflows || []).map((w) => react.createElement('option', { key: w.id, value: w.id }, `${w.name}(${(w.nodes || []).filter((n) => n.type === 'task').length} 阶段)`)),
287
+ ),
288
+ react.createElement('button', {
289
+ className: 'knj-btn primary',
290
+ disabled: this.state.busy || !this.state.newTitle,
291
+ onClick: async () => {
292
+ this.setState({ busy: true });
293
+ try {
294
+ await api('/tasks', { method: 'POST', body: { title: this.state.newTitle, workflowId: this.state.newWf || undefined } });
295
+ toast('任务已创建并启动');
296
+ this.setState({ newTitle: '', newWf: '', showNew: false });
297
+ this.props.onChanged();
298
+ } catch (e) { toast('创建失败:' + e.message); }
299
+ this.setState({ busy: false });
300
+ },
301
+ }, '创建并启动'),
302
+ ),
303
+ ) : null,
304
+ tasks.length === 0
305
+ ? react.createElement('div', { className: 'knj-empty' }, '暂无任务,点击「新建任务」开始')
306
+ : tasks.map((t) => react.createElement('div', { key: t.id, className: 'knj-card' },
307
+ react.createElement('div', { style: { flex: 1, cursor: 'pointer' }, onClick: () => this.loadDetail(t.id) },
308
+ react.createElement('div', { className: 'title' }, t.title),
309
+ react.createElement('div', { className: 'sub' }, `${t.id} · 工作流 ${t.workflowId}${t.currentStage ? ' · 阶段: ' + t.currentStage : ''}`),
310
+ ),
311
+ react.createElement('div', { className: 'knj-progress' }, react.createElement('div', { style: { width: progressPct(t) + '%' } })),
312
+ react.createElement('span', { className: 'knj-badge', style: { background: (STATUS_META[t.status] || STATUS_META.pending).color } }, (STATUS_META[t.status] || STATUS_META.pending).label),
313
+ (t.status === 'failed' || t.status === 'cancelled')
314
+ ? react.createElement('button', { className: 'knj-btn', onClick: async () => { try { await api('/tasks/' + t.id + '/resume', { method: 'POST', body: {} }); toast('已继续'); this.props.onChanged(); } catch (e) { toast(e.message); } } }, '继续')
315
+ : null,
316
+ )),
317
+ );
318
+ }
319
+ async loadDetail(id) {
320
+ try { const { task } = await api('/tasks/' + id); this.setState({ detail: task }); }
321
+ catch (e) { toast(e.message); }
322
+ }
323
+ }
324
+
325
+ class TaskDetail extends react.Component {
326
+ render() {
327
+ const { task, onBack } = this.props;
328
+ const steps = task.stageStates || [];
329
+ const pct = progressPct(task);
330
+ return react.createElement('div', null,
331
+ react.createElement('div', { className: 'knj-row' },
332
+ react.createElement('button', { className: 'knj-btn', onClick: onBack }, '← 返回'),
333
+ react.createElement('div', { style: { flex: 1 } },
334
+ react.createElement('div', { className: 'title', style: { fontSize: 15, fontWeight: 600 } }, task.title),
335
+ react.createElement('div', { className: 'sub' }, `${task.id} · 工作流 ${task.workflowId} · 进度 ${pct}%`),
336
+ ),
337
+ react.createElement('span', { className: 'knj-badge', style: { background: (STATUS_META[task.status] || STATUS_META.pending).color } }, (STATUS_META[task.status] || STATUS_META.pending).label),
338
+ ),
339
+ react.createElement('div', { className: 'knj-steps' },
340
+ steps.map((s, i) => {
341
+ const meta = STAGE_META[s.status] || STAGE_META.pending;
342
+ const isRunning = s.status === 'running';
343
+ return react.createElement('div', { key: s.id, className: 'knj-step' },
344
+ react.createElement('div', { className: 'link' + (s.status === 'done' ? ' done' : '') }),
345
+ react.createElement('div', { className: 'dot', style: { background: meta.color, ...(isRunning ? { boxShadow: '0 0 0 4px rgba(37,99,235,.2)' } : {}) } },
346
+ s.status === 'done' ? icon('check') : isRunning ? icon('clock') : s.status === 'skipped' ? '–' : (i + 1),
347
+ ),
348
+ react.createElement('div', { className: 'lbl' }, s.title),
349
+ react.createElement('div', { className: 'act' },
350
+ s.status === 'failed'
351
+ ? react.createElement('button', { className: 'knj-btn danger', onClick: () => this.rerunStage(s.id) }, '重跑')
352
+ : null,
353
+ ),
354
+ );
355
+ }),
356
+ ),
357
+ (task.status === 'failed' || task.status === 'cancelled')
358
+ ? react.createElement('div', { className: 'knj-row' },
359
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => this.resume() }, icon('play'), '继续'),
360
+ )
361
+ : null,
362
+ task.error ? react.createElement('div', { className: 'knj-err' }, '错误:' + task.error) : null,
363
+ );
364
+ }
365
+ async rerunStage(stageId) {
366
+ try {
367
+ await api('/tasks/' + this.props.task.id + '/rerun-stage', { method: 'POST', body: { stageId } });
368
+ toast('已重跑阶段:' + stageId);
369
+ setTimeout(() => this.props.onChanged(), 500);
370
+ } catch (e) { toast(e.message); }
371
+ }
372
+ async resume() {
373
+ try {
374
+ await api('/tasks/' + this.props.task.id + '/resume', { method: 'POST', body: {} });
375
+ toast('任务继续运行');
376
+ setTimeout(() => this.props.onChanged(), 500);
377
+ } catch (e) { toast(e.message); }
378
+ }
379
+ }
380
+
381
+ class WorkflowsView extends react.Component {
382
+ constructor(props) {
383
+ super(props);
384
+ this.state = { editing: null };
385
+ }
386
+ render() {
387
+ const { workflows } = this.props;
388
+ const { editing } = this.state;
389
+ if (editing) return react.createElement(GraphEditor, { workflow: editing, onBack: () => this.setState({ editing: null }), onSaved: async () => { this.props.onChanged(); this.setState({ editing: null }); } });
390
+ return react.createElement('div', null,
391
+ react.createElement('div', { className: 'knj-row' },
392
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => {
393
+ this.setState({ editing: { id: 'wf-' + Date.now().toString(36), name: '', description: '', schemaVersion: 2, inputs: [], nodes: [{ id: 'start', type: 'start' }, { id: 'end', type: 'end' }], edges: [] } });
394
+ } }, icon('plus'), '新建工作流'),
395
+ ),
396
+ (workflows || []).length === 0
397
+ ? react.createElement('div', { className: 'knj-empty' }, '暂无工作流')
398
+ : (workflows || []).map((w) => react.createElement('div', { key: w.id, className: 'knj-card' },
399
+ react.createElement('div', { style: { flex: 1 } },
400
+ react.createElement('div', { className: 'title' }, w.name),
401
+ react.createElement('div', { className: 'sub' }, `${w.id} · ${(w.nodes || []).filter((n) => n.type === 'task').length} 个阶段 · ${w.description || ''}`),
402
+ ),
403
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.setState({ editing: w }) }, '编辑'),
404
+ react.createElement('button', { className: 'knj-btn danger', onClick: async () => { try { await api('/workflows/' + w.id, { method: 'DELETE' }); this.props.onChanged(); } catch (e) { toast(e.message); } } }, '删除'),
405
+ )),
406
+ );
407
+ }
408
+ }
409
+
410
+ /** 沿边 from 是否可达 to(有向路径存在) */
411
+ function hasPath(edges, from, to) {
412
+ const seen = new Set();
413
+ const dfs = (id) => {
414
+ if (id === to) return true;
415
+ if (seen.has(id)) return false;
416
+ seen.add(id);
417
+ return edges.some((e) => e.from === id && dfs(e.to));
418
+ };
419
+ return dfs(from);
420
+ }
421
+
422
+ /** 分层自动布局:拓扑排序(忽略环边)→ 按深度分层 → 每层垂直排列。返回 { nodeId: {x,y} } */
423
+ function autoLayout(nodes, edges) {
424
+ const byId = new Map(nodes.map((n) => [n.id, n]));
425
+ const adj = new Map();
426
+ const inDeg = new Map();
427
+ nodes.forEach((n) => { adj.set(n.id, []); inDeg.set(n.id, 0); });
428
+ edges.forEach((e) => {
429
+ if (hasPath(edges, e.to, e.from)) return; // 环边:to 可达 from,跳过(不参与分层)
430
+ if (!adj.has(e.from) || !adj.has(e.to)) return;
431
+ adj.get(e.from).push(e.to);
432
+ inDeg.set(e.to, inDeg.get(e.to) + 1);
433
+ });
434
+ const queue = nodes.filter((n) => inDeg.get(n.id) === 0).map((n) => n.id);
435
+ const depth = new Map();
436
+ queue.forEach((id) => depth.set(id, 0));
437
+ while (queue.length) {
438
+ const id = queue.shift();
439
+ for (const to of adj.get(id) || []) {
440
+ const d = (depth.get(id) || 0) + 1;
441
+ if (!depth.has(to) || d > depth.get(to)) depth.set(to, d);
442
+ inDeg.set(to, inDeg.get(to) - 1);
443
+ if (inDeg.get(to) === 0) queue.push(to);
444
+ }
445
+ }
446
+ const maxD = Math.max(0, ...depth.values());
447
+ nodes.forEach((n) => { if (!depth.has(n.id)) depth.set(n.id, maxD); });
448
+ const layers = new Map();
449
+ depth.forEach((d, id) => { if (!layers.has(d)) layers.set(d, []); layers.get(d).push(id); });
450
+ const LAYER_W = 200, NODE_H = 96;
451
+ const pos = {};
452
+ [...layers.keys()].sort((a, b) => a - b).forEach((d) => {
453
+ const ids = layers.get(d);
454
+ const totalH = ids.length * NODE_H;
455
+ ids.forEach((id, i) => {
456
+ pos[id] = { x: 120 + d * LAYER_W, y: 80 + i * NODE_H + Math.max(0, (3 - ids.length) * NODE_H / 2) };
457
+ });
458
+ });
459
+ return pos;
460
+ }
461
+
462
+ /** 图编辑器:画布 + 节点面板 + 属性面板,自研 SVG */
463
+ class GraphEditor extends react.Component {
464
+ constructor(props) {
465
+ super(props);
466
+ const wf = props.workflow && Array.isArray(props.workflow.nodes)
467
+ ? JSON.parse(JSON.stringify(props.workflow))
468
+ : { id: 'wf-' + Date.now().toString(36), name: '', description: '', schemaVersion: 2, inputs: [], nodes: [{ id: 'start', type: 'start' }, { id: 'end', type: 'end' }], edges: [] };
469
+ // 初始布局:已有坐标(保存过的布局)就保留,缺失的用 autoLayout 补,避免首次渲染 SVG 坐标 NaN
470
+ const pos = autoLayout(wf.nodes, wf.edges);
471
+ wf.nodes = wf.nodes.map((n) => ({
472
+ ...n,
473
+ x: typeof n.x === 'number' ? n.x : (pos[n.id]?.x ?? 200),
474
+ y: typeof n.y === 'number' ? n.y : (pos[n.id]?.y ?? 200),
475
+ }));
476
+ this.state = { wf, selectedId: null, selectedEdge: null, drag: null, linking: null, hoverId: null, panning: null, zoom: 1, fullscreen: false, propsWidth: 380, resizing: null, schemaError: null, busy: false, error: null, showGuide: false, showNodeGuide: false, tooltip: null, canUndo: false, canRedo: false };
477
+ this.schemaRef = react.createRef();
478
+ // undo/redo:history 存"已提交状态序列"(栈顶 = 最近提交态,undo 恢复它的前一个);redoStack 存被撤销的状态
479
+ this.history = [JSON.parse(JSON.stringify(wf))];
480
+ this.redoStack = [];
481
+ this._commitTimer = null; // 输入防抖提交计时器
482
+ }
483
+ componentDidMount() {
484
+ document.addEventListener('keydown', this.onKeyDown);
485
+ }
486
+ componentWillUnmount() {
487
+ document.removeEventListener('keydown', this.onKeyDown);
488
+ }
489
+ onKeyDown = (e) => {
490
+ // 焦点在输入控件里时让浏览器走原生编辑撤销,不拦截
491
+ const t = e.target;
492
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
493
+ if ((e.ctrlKey || e.metaKey) && !e.altKey) {
494
+ const k = (e.key || '').toLowerCase();
495
+ if (k === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); }
496
+ else if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); this.redo(); }
497
+ }
498
+ }
499
+ /** 记录一个已提交状态进 history(与栈顶相同则跳过去重);清空 redo 栈 */
500
+ pushHistory(snap) {
501
+ const prev = this.history[this.history.length - 1];
502
+ if (prev && JSON.stringify(prev) === JSON.stringify(snap)) return;
503
+ this.history.push(snap);
504
+ if (this.history.length > 101) this.history.shift();
505
+ this.redoStack = [];
506
+ this.setState({ canUndo: this.history.length > 1, canRedo: false });
507
+ }
508
+ /** 一次性动作:变更完成后调用,提交变更后的状态(nextWf 为已变更的工作流) */
509
+ pushNow(nextWf) {
510
+ this.flushPending(); // 先提交 pending 输入,保证撤销顺序
511
+ this.pushHistory(JSON.parse(JSON.stringify(nextWf)));
512
+ }
513
+ /** 输入类变更:变更后调用,600ms 无输入后提交(合并连续输入为一个动作) */
514
+ scheduleCommit() {
515
+ clearTimeout(this._commitTimer);
516
+ this._commitTimer = setTimeout(() => {
517
+ this._commitTimer = null;
518
+ this.pushHistory(JSON.parse(JSON.stringify(this.state.wf)));
519
+ }, 600);
520
+ }
521
+ /** 提交待防抖的输入变更(立即入栈,保证顺序) */
522
+ flushPending() {
523
+ if (this._commitTimer) {
524
+ clearTimeout(this._commitTimer);
525
+ this._commitTimer = null;
526
+ this.pushHistory(JSON.parse(JSON.stringify(this.state.wf)));
527
+ }
528
+ }
529
+ undo() {
530
+ this.flushPending();
531
+ if (this.history.length < 2) return; // 栈里至少要有「当前提交态 + 一个历史」
532
+ this.redoStack.push(JSON.parse(JSON.stringify(this.state.wf)));
533
+ this.history.pop();
534
+ this.setState({ wf: this.history[this.history.length - 1], selectedId: null, selectedEdge: null, canUndo: this.history.length > 1, canRedo: true });
535
+ }
536
+ redo() {
537
+ if (!this.redoStack.length) return;
538
+ const cur = JSON.parse(JSON.stringify(this.state.wf));
539
+ const next = this.redoStack.pop();
540
+ this.history.push(cur);
541
+ if (this.history.length > 101) this.history.shift();
542
+ this.setState({ wf: next, selectedId: null, selectedEdge: null, canUndo: true, canRedo: this.redoStack.length > 0 });
543
+ }
544
+ relayout() {
545
+ const wf = this.state.wf;
546
+ const pos = autoLayout(wf.nodes, wf.edges);
547
+ wf.nodes = wf.nodes.map((n) => ({ ...n, x: pos[n.id].x, y: pos[n.id].y }));
548
+ this.pushNow(wf); // 自动布局作为一次可撤销动作
549
+ this.setState({ wf });
550
+ }
551
+ setWf(patch) { this.setState({ wf: { ...this.state.wf, ...patch } }); } // 名称/ID 元数据不进 undo
552
+ patchNode(id, patch, record = true) {
553
+ if (record) this.scheduleCommit();
554
+ const wf = this.state.wf;
555
+ wf.nodes = wf.nodes.map((n) => n.id === id ? { ...n, ...patch } : n);
556
+ this.setState({ wf });
557
+ }
558
+ /** 修改阶段编码(id):同步更新边与 human 跳转,prompt 引用需手动更新 */
559
+ patchNodeId(oldId, newId) {
560
+ if (!newId || newId === oldId) return;
561
+ if (!/^[a-zA-Z0-9._-]+$/.test(newId) || newId.includes('..')) { toast('阶段编码只能含字母/数字/._-'); return; }
562
+ this.scheduleCommit(); // 逐键输入防抖合并
563
+ const wf = this.state.wf;
564
+ if (wf.nodes.some((n) => n.id === newId && n.id !== oldId)) { toast('阶段编码已存在'); return; }
565
+ const nodes = wf.nodes.map((n) => {
566
+ if (n.id !== oldId) return n;
567
+ const u = { ...n, id: newId };
568
+ if (n.approveTo === oldId) u.approveTo = newId;
569
+ if (n.rejectTo === oldId) u.rejectTo = newId;
570
+ if (n.displayFrom === oldId) u.displayFrom = newId;
571
+ return u;
572
+ });
573
+ const edges = wf.edges.map((e) => ({ ...e, from: e.from === oldId ? newId : e.from, to: e.to === oldId ? newId : e.to }));
574
+ this.setState({ wf: { ...wf, nodes, edges } });
575
+ toast('阶段编码已更新为 ' + newId + ';prompt 里的 ${' + oldId + '.字段} 引用请手动改为 ${' + newId + '.字段}');
576
+ }
577
+ addNode(type) {
578
+ const wf = this.state.wf;
579
+ const id = type + '-' + Date.now().toString(36);
580
+ const base = { id, type, title: type, x: 200, y: 200 };
581
+ if (type === 'task') {
582
+ Object.assign(base, { inputs: [], body: { prompt: '', mode: 'single', output: {} }, prehook: [], posthook: [] });
583
+ } else if (type === 'human') {
584
+ // 人工节点不预设去向:通常在流程中间(如设计评审),通过/驳回去向由用户按需配置
585
+ Object.assign(base, { displayFrom: '', approveTo: '', rejectTo: '' });
586
+ }
587
+ wf.nodes.push(base);
588
+ this.pushNow(wf); // 新增节点后提交,撤销可移除
589
+ this.setState({ wf, selectedId: id });
590
+ }
591
+ removeNode(id) {
592
+ const wf = this.state.wf;
593
+ if (id === 'start' || id === 'end') return;
594
+ wf.nodes = wf.nodes.filter((n) => n.id !== id);
595
+ wf.edges = wf.edges.filter((e) => e.from !== id && e.to !== id);
596
+ this.pushNow(wf); // 删除后提交,撤销可恢复
597
+ this.setState({ wf, selectedId: null });
598
+ }
599
+ buildGraph() {
600
+ const wf = this.state.wf;
601
+ return {
602
+ id: wf.id, name: wf.name, description: wf.description || '',
603
+ schemaVersion: 2, inputs: wf.inputs || [],
604
+ nodes: wf.nodes.map((n) => {
605
+ const out = { ...n, inputs: (n.inputs || []).filter((r) => r.from && r.field) };
606
+ // 人工节点统一输出 routes(旧 approveTo/rejectTo 自动迁移为 通过/驳回 两条去向)
607
+ if (n.type === 'human' && !Array.isArray(n.routes)) {
608
+ const routes = [];
609
+ if (n.approveTo) routes.push({ label: '通过', to: n.approveTo, tone: 'success' });
610
+ if (n.rejectTo) routes.push({ label: '驳回', to: n.rejectTo, tone: 'danger' });
611
+ out.routes = routes;
612
+ }
613
+ return out;
614
+ }),
615
+ edges: wf.edges.map((e) => {
616
+ // op=in 时 value 从逗号分隔字符串转成数组;空 when 去掉 field/op/value
617
+ if (e.when && e.when.op === 'in' && typeof e.when.value === 'string') {
618
+ return { ...e, when: { ...e.when, value: e.when.value.split(',').map((s) => s.trim()).filter(Boolean) } };
619
+ }
620
+ if (e.when && !e.when.field) return { ...e, when: null };
621
+ return e;
622
+ }),
623
+ };
624
+ }
625
+ async save() {
626
+ const wf = this.state.wf;
627
+ if (!wf.name || !wf.id) { toast('请填写工作流名称和 ID'); return; }
628
+ const graph = this.buildGraph();
629
+ this.setState({ busy: true });
630
+ try {
631
+ await api('/workflows', { method: 'POST', body: graph });
632
+ toast('已保存');
633
+ // 保存成功 = 新基线:清空 undo/redo,防止撤销跨保存边界
634
+ this.flushPending();
635
+ this.history = [JSON.parse(JSON.stringify(this.state.wf))];
636
+ this.redoStack = [];
637
+ this.setState({ canUndo: false, canRedo: false });
638
+ this.props.onSaved();
639
+ } catch (e) {
640
+ toast('保存失败:' + e.message);
641
+ this.setState({ busy: false });
642
+ }
643
+ }
644
+ // 画布交互:节点拖拽
645
+ startDrag(id, e) {
646
+ const node = this.state.wf.nodes.find((n) => n.id === id);
647
+ const p = this.canvasPoint(e.clientX, e.clientY);
648
+ this.setState({ drag: { id, ox: p.x - node.x, oy: p.y - node.y } });
649
+ }
650
+ // 拖拽连线(从端口拖出)
651
+ startLink(id, e) {
652
+ e.stopPropagation();
653
+ const node = this.state.wf.nodes.find((n) => n.id === id);
654
+ this.setState({ linking: { fromId: id, x: node.x, y: node.y } });
655
+ }
656
+ render() {
657
+ const { onBack } = this.props;
658
+ const { wf, selectedId, drag, linking } = this.state;
659
+ const selected = wf.nodes.find((n) => n.id === selectedId);
660
+ return react.createElement('div', { className: 'knj-graph-editor' + (this.state.fullscreen ? ' knj-fullscreen' : ''), onMouseMove: (e) => this.onCanvasMove(e), onMouseUp: (e) => this.onCanvasUp(e) },
661
+ react.createElement('div', { className: 'knj-ge-head' },
662
+ react.createElement('button', { className: 'knj-btn', onClick: onBack }, '← 返回'),
663
+ react.createElement('input', { className: 'knj-input', style: { flex: 1, maxWidth: 260 }, placeholder: '工作流名称', value: wf.name || '', onChange: (e) => this.setWf({ name: e.target.value }) }),
664
+ react.createElement('input', { className: 'knj-input', style: { maxWidth: 180 }, placeholder: 'ID(唯一)', value: wf.id || '', onChange: (e) => this.setWf({ id: e.target.value }) }),
665
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.home(), title: '回到初始视图' }, '⌂'),
666
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.zoomBy(-0.2), title: '缩小' }, '−'),
667
+ react.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', minWidth: 40, textAlign: 'center' } }, `${Math.round(this.state.zoom * 100)}%`),
668
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.zoomBy(0.2), title: '放大' }, '+'),
669
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.setState({ fullscreen: !this.state.fullscreen }), title: '全屏' }, this.state.fullscreen ? '退出全屏' : '⛶ 全屏'),
670
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.relayout() }, '自动布局'),
671
+ react.createElement('button', { className: 'knj-btn', disabled: !this.state.canUndo, onClick: () => this.undo(), title: '撤销 (Ctrl+Z)' }, '↶'),
672
+ react.createElement('button', { className: 'knj-btn', disabled: !this.state.canRedo, onClick: () => this.redo(), title: '重做 (Ctrl+Y)' }, '↷'),
673
+ react.createElement('button', { className: 'knj-btn', title: '工作流配置说明(节点 / 网关 / 连线 / 人工审批)', onClick: () => this.setState({ showGuide: true }) }, '❓ 配置说明'),
674
+ react.createElement('button', { className: 'knj-btn primary', disabled: this.state.busy, onClick: () => this.save() }, '保存'),
675
+ ),
676
+ react.createElement('div', { className: 'knj-ge-body', style: { gridTemplateColumns: `150px 1fr 5px ${this.state.propsWidth}px` } },
677
+ react.createElement('div', { className: 'knj-ge-palette' },
678
+ react.createElement('div', { className: 'knj-form-label' }, '添加节点'),
679
+ ['task', 'gateway-xor', 'gateway-and', 'human'].map((t) => react.createElement('button', { key: t, className: 'knj-btn', style: { marginBottom: 6 }, onClick: () => this.addNode(t) }, NODE_LABEL[t] || t)),
680
+ react.createElement('div', { className: 'knj-form-label', style: { marginTop: 12 } }, '说明'),
681
+ react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 } }, '拖节点移动;从节点右侧圆点拖到目标节点连线;点节点编辑配置;点边编辑条件。'),
682
+ ),
683
+ react.createElement('div', { className: 'knj-ge-canvas' },
684
+ this.renderSvg(),
685
+ ),
686
+ react.createElement('div', { className: 'knj-ge-resizer', onMouseDown: (e) => this.startResize(e), title: '拖拽调整配置区宽度' }),
687
+ react.createElement('div', { className: 'knj-ge-props' },
688
+ selected
689
+ ? this.renderNodeProps(selected)
690
+ : this.state.selectedEdge
691
+ ? this.renderEdgeProps()
692
+ : react.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, padding: 12 } }, '点击节点编辑配置,点击连线可删除'),
693
+ ),
694
+ ),
695
+ this.state.showGuide ? this.renderGuideModal() : null,
696
+ this.state.showNodeGuide ? this.renderNodeGuideModal() : null,
697
+ this.state.tooltip ? react.createElement('div', { style: { position: 'fixed', left: this.state.tooltip.x + 14, top: this.state.tooltip.y + 14, zIndex: 1400, background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: '8px 10px', fontSize: 12, color: 'var(--dsw-alias-label-primary)', maxWidth: 340, boxShadow: 'var(--dsw-shadow-lv3)', pointerEvents: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, this.state.tooltip.text) : null,
698
+ );
699
+ }
700
+ renderGuideModal() {
701
+ const sec = (title, rows) => react.createElement('div', { style: { marginBottom: 14 } },
702
+ react.createElement('div', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', marginBottom: 6 } }, title),
703
+ react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.8 } }, rows.map((r, i) => react.createElement('div', { key: i, style: { marginBottom: 4 } }, r))),
704
+ );
705
+ return react.createElement('div', { className: 'knj-overlay', style: { zIndex: 1200 } },
706
+ react.createElement('div', { className: 'knj-panel', style: { width: 'min(760px, 92vw)', maxHeight: '86vh' } },
707
+ react.createElement('div', { className: 'knj-head' },
708
+ react.createElement('h2', null, '工作流配置说明'),
709
+ react.createElement('button', { className: 'knj-close', onClick: () => this.setState({ showGuide: false }) }, '关闭'),
710
+ ),
711
+ react.createElement('div', { className: 'knj-body' },
712
+ sec('节点(阶段)', [
713
+ '标题:节点在画布和阶段列表里显示的名称,如「需求分析」。',
714
+ '编码(id):节点的唯一标识,下游用 ${编码.字段} 引用(如 ${analyze.description})。新建节点时自动生成,可改;改后旧引用需手动更新。',
715
+ ]),
716
+ sec('行为 prompt(task 节点)', [
717
+ '本节点让 AI 做什么,写清楚目标和约束。',
718
+ '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${上游编码.字段}(上游输出,如 ${analyze.description})、${inputs.参数名}(任务输入)。',
719
+ '执行方式:single 一个 AI 完成;parallel 派多个 AI 并行(结果合并为数组)。',
720
+ '最大执行次数:本节点在一轮任务里最多执行几次(默认 3)。驳回重做 / 循环连线会重复执行节点,超过上限本轮失败——自动防死循环,无需其他配置。',
721
+ ]),
722
+ sec('节点输出(本阶段产物)', [
723
+ '定义本节点完成后的输出字段:字段名、类型、说明。',
724
+ '下游阶段用 ${编码.字段} 引用;网关用字段值做分支判断(如 level eq high)。',
725
+ '布尔 / 数组 / 枚举建议显式指定类型,判断才可靠。',
726
+ ]),
727
+ sec('网关(分支 / 并行)', [
728
+ 'XOR(多选一):按条件走一条分支——给边配 when(字段 + 比较值),如 level eq high → 设计。',
729
+ 'AND(全走):并行执行多条分支,全部完成后合并继续。',
730
+ '条件值可引用任务变量,如 ${inputs.level}。',
731
+ ]),
732
+ sec('人工审批(human 节点)', [
733
+ '展示产物:审批时显示哪个上游节点的输出(如 verify)。',
734
+ '去向(可多条):每条 = 标签 + 目标节点 + 色调。运行到本节点时暂停等待,审批界面每个去向一个按钮(标签即按钮文字),点击后走到对应目标。',
735
+ '可附意见(可选):填写的意见会注入去向目标节点,供后续 agent 参考。',
736
+ ]),
737
+ sec('连线与条件', [
738
+ '从节点右侧圆点拖到目标节点即可连线。',
739
+ '点连线可编辑条件(when):字段 + 比较方式(等于/不等于/包含)+ 值。',
740
+ '配了条件的最先命中;都不命中走 default(带「默认」标记的边)。',
741
+ ]),
742
+ sec('prehook / posthook(执行前后动作)', [
743
+ '节点执行前/后可执行额外动作(如 git pull、写文件)。可选,通常不用配。',
744
+ ]),
745
+ ),
746
+ ),
747
+ );
748
+ }
749
+ /** 节点配置手册:只讲节点各字段(区别于顶部「工作流配置说明」) */
750
+ renderNodeGuideModal() {
751
+ const sec = (title, rows) => react.createElement('div', { style: { marginBottom: 14 } },
752
+ react.createElement('div', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', marginBottom: 6 } }, title),
753
+ react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.8 } }, rows.map((r, i) => react.createElement('div', { key: i, style: { marginBottom: 4 } }, r))),
754
+ );
755
+ return react.createElement('div', { className: 'knj-overlay', style: { zIndex: 1200 } },
756
+ react.createElement('div', { className: 'knj-panel', style: { width: 'min(680px, 92vw)', maxHeight: '86vh' } },
757
+ react.createElement('div', { className: 'knj-head' },
758
+ react.createElement('h2', null, '节点配置说明'),
759
+ react.createElement('button', { className: 'knj-close', onClick: () => this.setState({ showNodeGuide: false }) }, '关闭'),
760
+ ),
761
+ react.createElement('div', { className: 'knj-body' },
762
+ sec('通用字段(所有节点)', [
763
+ '节点标题:节点在画布和阶段列表里的显示名,如「需求分析」。仅展示用,不影响逻辑。',
764
+ '节点编码(id):唯一标识,下游用 ${编码.字段} 引用(如 ${analyze.description})。修改后旧的 ${旧编码.字段} 引用需手动更新。',
765
+ ]),
766
+ sec('行为 prompt(task 节点)', [
767
+ '本节点让 AI 做什么,写清目标与约束。',
768
+ '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${inputs.参数名}(任务输入)。',
769
+ '需要 skill 时在 prompt 里直接写「先调用 skill 工具加载 skill「xxx」」。',
770
+ '上游输出引用(${上游编码.字段}):',
771
+ ...this.buildRefsText(this.state.wf.nodes.find((n) => n.id === this.state.selectedId)).split('\n').map((s) => ' ' + s),
772
+ '注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。',
773
+ ]),
774
+ sec('执行方式', [
775
+ 'single:一个 AI 完成本阶段,输出单个对象。',
776
+ 'parallel:派多个 AI 并行做同一件事,结果合并为数组(每个分片写不同 stage 文件)。',
777
+ ]),
778
+ sec('最大执行次数', [
779
+ '本节点在一轮任务里最多执行几次(默认 3:首次 + 最多 2 次重跑)。',
780
+ '驳回重做 / 循环连线会重复执行节点,超过上限本轮失败——自动防死循环,无需其他配置。',
781
+ ]),
782
+ sec('节点输出(本阶段产物)', [
783
+ '定义本节点完成后的输出字段:字段名、类型、说明。',
784
+ '下游阶段用 ${编码.字段} 引用;网关用字段值做分支判断(如 level eq high)。',
785
+ '布尔 / 数组 / 枚举建议显式指定类型,判断才可靠;字段说明(含义/格式/来源)会注入 subagent 的 schema。',
786
+ '并行执行(parallel)时结果合并为数组。',
787
+ ]),
788
+ sec('prehook / posthook(执行前后动作)', [
789
+ '节点执行前/后可执行额外动作(如 git pull、写文件)。可选,通常不用配。',
790
+ ]),
791
+ sec('人工节点字段(human)', [
792
+ '展示产物:审批时显示哪个上游节点的输出(如 verify),审批人先看产物再决定。',
793
+ '去向(可多条):每条 = 标签(审批按钮文字)+ 目标节点 + 色调。运行到本节点时暂停等待,审批界面每个去向一个按钮。',
794
+ '意见:审批时可选填写,会注入去向目标节点,供后续 agent 参考。',
795
+ ]),
796
+ ),
797
+ ),
798
+ );
799
+ }
800
+ onCanvasMove(e) {
801
+ const { drag, linking, wf, panning, resizing } = this.state;
802
+ const canvas = e.currentTarget.querySelector('.knj-ge-canvas');
803
+ if (drag) {
804
+ const node = wf.nodes.find((n) => n.id === drag.id);
805
+ const p = this.canvasPoint(e.clientX, e.clientY);
806
+ const nx = p.x - drag.ox, ny = p.y - drag.oy;
807
+ this.patchNode(drag.id, { x: Math.max(40, nx), y: Math.max(30, ny) }, false); // 拖拽坐标不逐帧记录,onCanvasUp 统一入栈
808
+ } else if (linking) {
809
+ const p = this.canvasPoint(e.clientX, e.clientY);
810
+ const x = p.x, y = p.y;
811
+ let hoverId = null;
812
+ for (const n of wf.nodes) {
813
+ if (n.id === linking.fromId) continue;
814
+ if (Math.abs(x - n.x) < 70 && Math.abs(y - n.y) < 42) { hoverId = n.id; break; }
815
+ }
816
+ this.setState({ linking: { ...linking, x, y }, hoverId });
817
+ } else if (panning) {
818
+ canvas.scrollLeft = panning.sx - (e.clientX - panning.cx);
819
+ canvas.scrollTop = panning.sy - (e.clientY - panning.cy);
820
+ } else if (resizing) {
821
+ const w = Math.min(700, Math.max(260, resizing.sw - (e.clientX - resizing.sx)));
822
+ this.setState({ propsWidth: w });
823
+ }
824
+ }
825
+ onCanvasUp(e) {
826
+ const { drag, linking, panning, resizing } = this.state;
827
+ if (drag) {
828
+ this.setState({ drag: null });
829
+ this.pushNow(this.state.wf); // 拖拽完成提交(拖拽中坐标变更未记录,这里作为一次动作)
830
+ }
831
+ if (linking) {
832
+ const target = this.nodeAt(e.clientX, e.clientY, linking.fromId);
833
+ if (target) {
834
+ const wf = this.state.wf;
835
+ if (!wf.edges.some((ed) => ed.from === linking.fromId && ed.to === target)) {
836
+ wf.edges.push({ from: linking.fromId, to: target });
837
+ this.pushNow(wf); // 新增连线后提交,撤销可移除
838
+ this.setState({ wf });
839
+ }
840
+ }
841
+ this.setState({ linking: null, hoverId: null });
842
+ }
843
+ if (panning) this.setState({ panning: null });
844
+ if (resizing) this.setState({ resizing: null });
845
+ }
846
+ nodeAt(cx, cy, fromId) {
847
+ const p = this.canvasPoint(cx, cy);
848
+ const x = p.x, y = p.y;
849
+ for (const n of this.state.wf.nodes) {
850
+ if (n.id === fromId) continue;
851
+ if (Math.abs(x - n.x) < 70 && Math.abs(y - n.y) < 42) return n.id;
852
+ }
853
+ return null;
854
+ }
855
+ startPan(e) {
856
+ if (e.button !== 0) return;
857
+ const canvas = document.querySelector('.knj-ge-canvas');
858
+ if (!canvas) return;
859
+ this.setState({ panning: { cx: e.clientX, cy: e.clientY, sx: canvas.scrollLeft, sy: canvas.scrollTop } });
860
+ }
861
+ home() {
862
+ this.relayout();
863
+ const canvas = document.querySelector('.knj-ge-canvas');
864
+ if (canvas) { canvas.scrollLeft = 0; canvas.scrollTop = 0; }
865
+ this.setState({ selectedId: null, selectedEdge: null, zoom: 1 });
866
+ }
867
+ zoomBy(delta) {
868
+ const z = Math.min(2, Math.max(0.5, Math.round((this.state.zoom + delta) * 10) / 10));
869
+ this.setState({ zoom: z });
870
+ }
871
+ /** 屏幕坐标 → 画布内容坐标(含滚动偏移 + 缩放比例) */
872
+ canvasPoint(cx, cy) {
873
+ const canvas = document.querySelector('.knj-ge-canvas');
874
+ const rect = canvas ? canvas.getBoundingClientRect() : { left: 0, top: 0 };
875
+ const scx = canvas ? canvas.scrollLeft : 0, scy = canvas ? canvas.scrollTop : 0;
876
+ return { x: (cx - rect.left + scx) / this.state.zoom, y: (cy - rect.top + scy) / this.state.zoom };
877
+ }
878
+ startResize(e) {
879
+ e.preventDefault();
880
+ this.setState({ resizing: { sx: e.clientX, sw: this.state.propsWidth } });
881
+ }
882
+ isUpstream(a, b) {
883
+ // a 是否是 b 的上游(沿边可达)→ 用于判断环边(to 可达 from 即构成环)
884
+ const wf = this.state.wf;
885
+ const seen = new Set();
886
+ const dfs = (id) => {
887
+ if (id === a) return true;
888
+ if (seen.has(id)) return false;
889
+ seen.add(id);
890
+ return wf.edges.some((e) => e.from === id && dfs(e.to));
891
+ };
892
+ return dfs(b);
893
+ }
894
+ renderSvg() {
895
+ const { wf, linking, selectedId } = this.state;
896
+ const xs = wf.nodes.map((n) => n.x), ys = wf.nodes.map((n) => n.y);
897
+ const maxX = Math.max(700, ...xs) + 200, maxY = Math.max(420, ...ys) + 200;
898
+ return react.createElement('svg', { className: 'knj-ge-svg', width: maxX * this.state.zoom, height: maxY * this.state.zoom },
899
+ react.createElement('defs', null,
900
+ react.createElement('marker', { id: 'knj-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 11, markerHeight: 11, orient: 'auto' },
901
+ react.createElement('path', { d: 'M0 0L10 5L0 10z', fill: '#5b6472' })),
902
+ react.createElement('marker', { id: 'knj-arrow-red', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 11, markerHeight: 11, orient: 'auto' },
903
+ react.createElement('path', { d: 'M0 0L10 5L0 10z', fill: '#dc2626' })),
904
+ react.createElement('marker', { id: 'knj-arrow-green', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 11, markerHeight: 11, orient: 'auto' },
905
+ react.createElement('path', { d: 'M0 0L10 5L0 10z', fill: '#16a34a' })),
906
+ react.createElement('marker', { id: 'knj-arrow-blue', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 11, markerHeight: 11, orient: 'auto' },
907
+ react.createElement('path', { d: 'M0 0L10 5L0 10z', fill: '#2f6bff' })),
908
+ react.createElement('marker', { id: 'knj-arrow-orange', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 11, markerHeight: 11, orient: 'auto' },
909
+ react.createElement('path', { d: 'M0 0L10 5L0 10z', fill: '#d97706' })),
910
+ ),
911
+ // 空白区域:按下拖拽平移画布
912
+ react.createElement('rect', { x: 0, y: 0, width: maxX, height: maxY, fill: 'transparent', onMouseDown: (e) => this.startPan(e), style: { cursor: 'grab' } }),
913
+ wf.edges.map((e) => this.renderEdge(e)),
914
+ this.renderHumanEdges(),
915
+ wf.nodes.map((n) => this.renderNode(n, selectedId === n.id)),
916
+ linking ? this.renderLinking(linking) : null,
917
+ );
918
+ }
919
+ renderLinking(linking) {
920
+ const from = this.state.wf.nodes.find((n) => n.id === linking.fromId);
921
+ if (!from) return null;
922
+ const sx = from.type === 'start' ? from.x + 30 : from.x + 60, sy = from.y, tx = linking.x, ty = linking.y;
923
+ const mx = (sx + tx) / 2;
924
+ const d = `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ty}, ${tx} ${ty}`;
925
+ return react.createElement('path', { d, fill: 'none', stroke: '#2f6bff', strokeWidth: 2, strokeDasharray: '6 4', markerEnd: 'url(#knj-arrow-blue)' });
926
+ }
927
+ halfW(n) {
928
+ // 连线端点半宽:网关是菱形(半对角线 30),start/end 是胶囊(半宽 30),其余是矩形(半宽 60)
929
+ if (n.type === 'gateway-xor' || n.type === 'gateway-and') return 30;
930
+ if (n.type === 'start' || n.type === 'end') return 30;
931
+ return 60;
932
+ }
933
+ renderEdge(e) {
934
+ const wf = this.state.wf;
935
+ const from = wf.nodes.find((n) => n.id === e.from), to = wf.nodes.find((n) => n.id === e.to);
936
+ if (!from || !to) return null;
937
+ const key = e.from + '->' + e.to;
938
+ const isSel = this.state.selectedEdge === key;
939
+ const cyc = this.isUpstream(e.to, e.from); // 环边:to 可达 from(如人工驳回后重跑链路)
940
+ const sx = from.x + this.halfW(from), sy = from.y, tx = to.x - this.halfW(to), ty = to.y;
941
+ const mx = (sx + tx) / 2, my = (sy + ty) / 2; // 贝塞尔曲线中点,标签放这里
942
+ const d = `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ty}, ${tx} ${ty}`;
943
+ const pick = (ev) => { ev.stopPropagation(); this.setState({ selectedEdge: key, selectedId: null }); };
944
+ return react.createElement('g', { key },
945
+ // 透明点击区域(加宽,便于选边)
946
+ react.createElement('path', { d, fill: 'none', stroke: 'transparent', strokeWidth: 16, onClick: pick, style: { cursor: 'pointer' } }),
947
+ // 可见线
948
+ react.createElement('path', { d, fill: 'none', stroke: cyc ? '#dc2626' : (isSel ? '#2f6bff' : '#5b6472'), strokeWidth: isSel ? 3 : 2, ...(cyc ? { strokeDasharray: '5 3' } : {}), markerEnd: cyc ? 'url(#knj-arrow-red)' : (isSel ? 'url(#knj-arrow-blue)' : 'url(#knj-arrow)') }),
949
+ (e.when || e.default) ? react.createElement('g', { onClick: pick, style: { cursor: 'pointer' } },
950
+ react.createElement('rect', { x: mx - 44, y: my - 11, width: 88, height: 18, rx: 4, fill: '#fff', stroke: e.default ? '#16a34a' : (isSel ? '#2f6bff' : '#e2e6ec') }),
951
+ react.createElement('text', { x: mx, y: my + 1, fontSize: 10, textAnchor: 'middle', fill: e.default ? '#16a34a' : '#5b6472' },
952
+ e.default && e.when ? `default · ${e.when.field} ${e.when.op} ${JSON.stringify(e.when.value)}` : (e.default ? 'default' : (e.when ? `${e.when.field} ${e.when.op} ${JSON.stringify(e.when.value)}` : ''))),
953
+ ) : null,
954
+ );
955
+ }
956
+ renderHumanEdges() {
957
+ const wf = this.state.wf;
958
+ const lines = [];
959
+ for (const n of wf.nodes) {
960
+ if (n.type !== 'human') continue;
961
+ humanRoutes(n).forEach((route, i) => {
962
+ if (route.to) lines.push({ from: n.id, to: route.to, route, index: i });
963
+ });
964
+ }
965
+ return lines.map((he) => this.renderHumanEdge(he));
966
+ }
967
+ renderHumanEdge(he) {
968
+ const wf = this.state.wf;
969
+ const from = wf.nodes.find((n) => n.id === he.from), to = wf.nodes.find((n) => n.id === he.to);
970
+ if (!from || !to) return null;
971
+ const color = routeColor(he.route);
972
+ const key = 'human:' + he.from + ':' + he.index;
973
+ const isSel = this.state.selectedEdge === key;
974
+ const sx = from.x + this.halfW(from), sy = from.y, tx = to.x - this.halfW(to), ty = to.y;
975
+ const mx = (sx + tx) / 2, my = (sy + ty) / 2; // 贝塞尔曲线中点,标签放这里
976
+ const d = `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ty}, ${tx} ${ty}`;
977
+ const pick = (ev) => { ev.stopPropagation(); this.setState({ selectedEdge: key, selectedId: null }); };
978
+ const label = he.route.label || '去向';
979
+ const markerEnd = ({ success: 'url(#knj-arrow-green)', danger: 'url(#knj-arrow-red)', warning: 'url(#knj-arrow-orange)', info: 'url(#knj-arrow-blue)', neutral: 'url(#knj-arrow)' })[he.route.tone] || 'url(#knj-arrow-blue)';
980
+ return react.createElement('g', { key },
981
+ // 透明点击区域(加宽,便于选中)
982
+ react.createElement('path', { d, fill: 'none', stroke: 'transparent', strokeWidth: 16, onClick: pick, style: { cursor: 'pointer' } }),
983
+ react.createElement('path', { d, fill: 'none', stroke: color, strokeWidth: isSel ? 3 : 2, strokeDasharray: '5 4', markerEnd }),
984
+ react.createElement('g', { onClick: pick, style: { cursor: 'pointer' } },
985
+ react.createElement('rect', { x: mx - 26, y: my - 11, width: 52, height: 16, rx: 3, fill: '#fff', stroke: isSel ? '#2f6bff' : color, strokeWidth: isSel ? 2 : 1 }),
986
+ react.createElement('text', { x: mx, y: my + 1, fontSize: 10, textAnchor: 'middle', fill: color }, label.length > 5 ? label.slice(0, 5) + '…' : label),
987
+ ),
988
+ );
989
+ }
990
+ renderNode(n, isSel) {
991
+ const colors = { task: '#5b6472', 'gateway-xor': '#2f6bff', 'gateway-and': '#16a34a', human: '#7c5cff', start: '#8b95a5', end: '#8b95a5' };
992
+ const c = colors[n.type] || '#5b6472';
993
+ // 人工节点去向缺失 → 红色描边 + 角标警示
994
+ const needTarget = n.type === 'human' && humanRoutesIncomplete(n);
995
+ const body = (() => {
996
+ if (n.type === 'gateway-xor' || n.type === 'gateway-and') {
997
+ const half = 30;
998
+ return react.createElement('g', null,
999
+ react.createElement('path', { d: `M ${n.x} ${n.y - half} L ${n.x + half} ${n.y} L ${n.x} ${n.y + half} L ${n.x - half} ${n.y} Z`, fill: '#fff', stroke: c, strokeWidth: 1.6 }),
1000
+ react.createElement('path', { d: n.type === 'gateway-xor' ? `M ${n.x - 9} ${n.y - 9} h18 M ${n.x - 9} ${n.y + 9} h18` : `M ${n.x - 9} ${n.y} h18`, stroke: c, strokeWidth: 1.3 }),
1001
+ );
1002
+ }
1003
+ const w = 120, h = 44;
1004
+ if (n.type === 'start' || n.type === 'end') {
1005
+ return react.createElement('rect', { x: n.x - 30, y: n.y - 18, width: 60, height: 36, rx: 18, fill: '#fff', stroke: c, strokeWidth: 1.5 });
1006
+ }
1007
+ return react.createElement('rect', { x: n.x - w / 2, y: n.y - h / 2, width: w, height: h, rx: 8, fill: n.type === 'human' ? '#f0ecff' : '#fff', stroke: needTarget ? '#dc2626' : c, strokeWidth: needTarget ? 2 : 1.6 });
1008
+ })();
1009
+ const label = (() => {
1010
+ if (n.type === 'gateway-xor' || n.type === 'gateway-and') return null;
1011
+ const txt = n.type === 'start' ? '开始' : n.type === 'end' ? '结束' : (n.title || n.id);
1012
+ return react.createElement('text', { x: n.x, y: n.y + 4, fontSize: 12, textAnchor: 'middle', fill: '#181b21' }, txt.length > 10 ? txt.slice(0, 10) + '…' : txt);
1013
+ })();
1014
+ const isHover = this.state.linking && this.state.hoverId === n.id;
1015
+ // 节点类型图标(左上角):task 齿轮 / human 小人(用节点描边色)
1016
+ const typeIcon = (n.type === 'task' || n.type === 'human')
1017
+ ? react.createElement('g', { transform: `translate(${n.x - 52}, ${n.y - 14})` },
1018
+ react.createElement('svg', { width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none' },
1019
+ n.type === 'human'
1020
+ ? [
1021
+ react.createElement('circle', { key: 'h1', cx: 8, cy: 5.5, r: 3, stroke: c, strokeWidth: 1.6 }),
1022
+ react.createElement('path', { key: 'h2', d: 'M2.5 13.5c0-3 2.5-4.5 5.5-4.5s5.5 1.5 5.5 4.5', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
1023
+ ]
1024
+ : [
1025
+ react.createElement('circle', { key: 'g1', cx: 8, cy: 8, r: 2.2, stroke: c, strokeWidth: 1.5 }),
1026
+ react.createElement('path', { key: 'g2', d: 'M8 1.5v2.3M8 12.2v2.3M1.5 8h2.3M12.2 8h2.3M3.4 3.4l1.6 1.6M11 11l1.6 1.6M12.6 3.4L11 5M5 11l-1.6 1.6', stroke: c, strokeWidth: 1.5, strokeLinecap: 'round' }),
1027
+ ],
1028
+ ),
1029
+ )
1030
+ : null;
1031
+ return react.createElement('g', { key: n.id, onMouseDown: (e) => this.startDrag(n.id, e), onClick: (e) => { e.stopPropagation(); this.setState({ selectedId: n.id, selectedEdge: null }); }, style: { cursor: 'move' } },
1032
+ react.createElement('rect', { x: n.x - 62, y: n.y - 24, width: 124, height: 48, fill: isSel ? 'rgba(47,107,255,0.08)' : (isHover ? 'rgba(47,107,255,0.14)' : 'transparent'), stroke: isSel ? '#2f6bff' : (isHover ? '#2f6bff' : 'none'), strokeWidth: isHover ? 2 : 1, strokeDasharray: isHover ? '4 3' : undefined, rx: 10 }),
1033
+ body,
1034
+ label,
1035
+ typeIcon,
1036
+ needTarget ? react.createElement('g', null,
1037
+ react.createElement('circle', { cx: n.x + 52, cy: n.y - 14, r: 5, fill: '#dc2626' }),
1038
+ react.createElement('title', null, '人工节点缺少有效去向(标签 + 目标节点),点节点配置'),
1039
+ ) : null,
1040
+ // 连线端口(右侧圆点,hover 时高亮)
1041
+ n.type !== 'end' ? react.createElement('g', { onMouseDown: (e) => this.startLink(n.id, e), style: { cursor: 'crosshair' } },
1042
+ react.createElement('circle', { cx: n.x + 60, cy: n.y, r: 7, fill: '#2f6bff', opacity: 0.9 }),
1043
+ react.createElement('circle', { cx: n.x + 60, cy: n.y, r: 2.5, fill: '#fff' }),
1044
+ ) : null,
1045
+ );
1046
+ }
1047
+ renderEdgeProps() {
1048
+ const key = this.state.selectedEdge;
1049
+ // 人工决策边(通过/驳回):key 形如 human:approve:<nodeId> / human:reject:<nodeId>
1050
+ if (key.startsWith('human:')) return this.renderHumanEdgeProps(key);
1051
+ const [from, to] = key.split('->');
1052
+ const edge = this.state.wf.edges.find((e) => e.from === from && e.to === to);
1053
+ if (!edge) return null;
1054
+ // 节点有多条出边时,非默认边需要条件(XOR 网关 / task 多出边一视同仁)
1055
+ const outCount = this.state.wf.edges.filter((e) => e.from === from).length;
1056
+ const needCondition = outCount > 1;
1057
+ return react.createElement('div', { className: 'knj-ge-props-inner' },
1058
+ react.createElement('div', { className: 'knj-form-label' }, '连线'),
1059
+ react.createElement('div', { style: { fontSize: 13 } }, `${from} → ${to}`),
1060
+ needCondition ? react.createElement('div', null,
1061
+ react.createElement('div', { className: 'knj-form-label', style: { marginTop: 10 } }, '条件(when)'),
1062
+ react.createElement('input', { className: 'knj-input', placeholder: '字段名(相对上游输出,如 complexity)', value: edge.when?.field || '', onChange: (e) => this.patchEdge(key, { when: { op: 'eq', value: '', ...edge.when, field: e.target.value } }) }),
1063
+ react.createElement('div', { className: 'knj-row', style: { marginTop: 8 } },
1064
+ react.createElement('select', { className: 'knj-input', style: { maxWidth: 120 }, value: edge.when?.op || 'eq', onChange: (e) => this.patchEdge(key, { when: { field: '', ...edge.when, op: e.target.value } }) },
1065
+ react.createElement('option', { value: 'eq' }, '等于'),
1066
+ react.createElement('option', { value: 'neq' }, '不等于'),
1067
+ react.createElement('option', { value: 'in' }, '属于'),
1068
+ ),
1069
+ react.createElement('input', { className: 'knj-input', placeholder: '值(in 用逗号分隔,如 high,low)', value: this.valueToText(edge.when?.value), onChange: (e) => this.patchEdge(key, { when: { ...edge.when, value: e.target.value } }) }),
1070
+ ),
1071
+ edge.when ? react.createElement('button', { className: 'knj-btn', style: { alignSelf: 'flex-start', marginTop: 6 }, onClick: () => this.patchEdge(key, { when: null }) }, '清除条件') : null,
1072
+ ) : react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginTop: 8 } }, '顺序边,无需条件判断'),
1073
+ // default 标记:节点有多条出边时可指定默认边(兜底;配了条件也参与匹配)
1074
+ outCount > 1 ? react.createElement('div', { className: 'knj-row', style: { marginTop: 10 } },
1075
+ react.createElement('label', { style: { fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', color: 'var(--dsw-alias-label-primary)' } },
1076
+ react.createElement('input', { type: 'checkbox', checked: !!edge.default, onChange: (e) => this.patchEdge(key, { default: e.target.checked }) }),
1077
+ '默认边(兜底:条件都不满足时走它;配了条件也参与匹配)',
1078
+ ),
1079
+ ) : null,
1080
+ react.createElement('button', { className: 'knj-btn danger', style: { alignSelf: 'flex-start', marginTop: 10 }, onClick: () => this.removeEdge(key) }, '删除连线'),
1081
+ );
1082
+ }
1083
+ /** 人工决策边面板:改去向(标签/目标/色调),或删除该去向 */
1084
+ renderHumanEdgeProps(key) {
1085
+ const parts = key.split(':');
1086
+ const nodeId = parts[1];
1087
+ const index = parseInt(parts[2], 10);
1088
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1089
+ if (!node) return null;
1090
+ const routes = humanRoutes(node);
1091
+ const route = routes[index];
1092
+ if (!route) return null;
1093
+ const color = routeColor(route);
1094
+ const targets = this.state.wf.nodes.filter((n) => n.id !== nodeId && n.type !== 'start').map((n) => n.id);
1095
+ return react.createElement('div', { className: 'knj-ge-props-inner' },
1096
+ react.createElement('div', { className: 'knj-form-label' }, '人工决策去向'),
1097
+ react.createElement('div', { style: { fontSize: 13 } }, `人工节点「${node.title || nodeId}」`),
1098
+ react.createElement('div', { className: 'knj-row', style: { marginTop: 8, gap: 8 } },
1099
+ react.createElement('input', { className: 'knj-input', style: { flex: 1 }, placeholder: '去向标签(审批按钮文字)', value: route.label || '', onChange: (e) => this.patchRoute(nodeId, index, { label: e.target.value }) }),
1100
+ react.createElement('select', { className: 'knj-input', style: { maxWidth: 96 }, value: route.tone || 'info', onChange: (e) => this.patchRoute(nodeId, index, { tone: e.target.value }) },
1101
+ react.createElement('option', { value: 'success' }, '通过色'),
1102
+ react.createElement('option', { value: 'danger' }, '驳回色'),
1103
+ react.createElement('option', { value: 'warning' }, '警告色'),
1104
+ react.createElement('option', { value: 'info' }, '信息色'),
1105
+ react.createElement('option', { value: 'neutral' }, '中性'),
1106
+ ),
1107
+ ),
1108
+ react.createElement('select', { className: 'knj-input', style: { marginTop: 8 }, value: route.to || '', onChange: (e) => this.patchRoute(nodeId, index, { to: e.target.value }) },
1109
+ route.to && !targets.includes(route.to) ? react.createElement('option', { value: route.to }, route.to + '(不存在)') : null,
1110
+ targets.map((t) => react.createElement('option', { key: t, value: t }, t)),
1111
+ ),
1112
+ react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', marginTop: 8, lineHeight: 1.6 } },
1113
+ '审批时该去向显示为一个按钮(标签即按钮文字),点击后流程走到目标节点;填写的意见会注入目标节点。',
1114
+ ),
1115
+ react.createElement('button', { className: 'knj-btn danger', style: { alignSelf: 'flex-start', marginTop: 10 }, onClick: () => this.removeRoute(nodeId, index) }, '删除该去向'),
1116
+ );
1117
+ }
1118
+ patchRoute(nodeId, index, patch) {
1119
+ const wf = this.state.wf;
1120
+ const node = wf.nodes.find((n) => n.id === nodeId);
1121
+ if (!node) return;
1122
+ const routes = humanRoutes(node).map((r, i) => i === index ? { ...r, ...patch } : r);
1123
+ this.patchNode(nodeId, { routes }); // patchNode 内部已做防抖提交
1124
+ }
1125
+ removeRoute(nodeId, index) {
1126
+ const wf = this.state.wf;
1127
+ const node = wf.nodes.find((n) => n.id === nodeId);
1128
+ if (!node) return;
1129
+ const routes = humanRoutes(node).filter((_, i) => i !== index);
1130
+ wf.nodes = wf.nodes.map((n) => n.id === nodeId ? { ...n, routes } : n);
1131
+ this.pushNow(wf); // 删除后提交,撤销可恢复
1132
+ this.setState({ wf, selectedEdge: null });
1133
+ }
1134
+ addRoute(nodeId) {
1135
+ const wf = this.state.wf;
1136
+ const node = wf.nodes.find((n) => n.id === nodeId);
1137
+ if (!node) return;
1138
+ const routes = [...humanRoutes(node), { label: '', to: '', tone: 'info' }];
1139
+ wf.nodes = wf.nodes.map((n) => n.id === nodeId ? { ...n, routes } : n);
1140
+ this.pushNow(wf); // 新增后提交,撤销可移除
1141
+ this.setState({ wf });
1142
+ }
1143
+ /** 去向编辑行:标签 + 色调 + 目标 + 删除 */
1144
+ renderRouteRow(node, route, i) {
1145
+ const targets = this.state.wf.nodes.filter((n) => n.id !== node.id && n.type !== 'start').map((n) => n.id);
1146
+ const color = routeColor(route);
1147
+ return react.createElement('div', { key: i, className: 'knj-stage-row', style: { alignItems: 'flex-start', gap: 6, marginBottom: 6 } },
1148
+ react.createElement('input', { className: 'knj-input', style: { flex: 'none', width: 72, borderColor: route.to ? undefined : '#dc2626' }, placeholder: '标签', value: route.label || '', onChange: (e) => this.patchRoute(node.id, i, { label: e.target.value }) }),
1149
+ react.createElement('select', { className: 'knj-input', style: { flex: 'none', width: 84 }, value: route.tone || 'info', onChange: (e) => this.patchRoute(node.id, i, { tone: e.target.value }) },
1150
+ react.createElement('option', { value: 'success' }, '通过色'),
1151
+ react.createElement('option', { value: 'danger' }, '驳回色'),
1152
+ react.createElement('option', { value: 'warning' }, '警告色'),
1153
+ react.createElement('option', { value: 'info' }, '信息色'),
1154
+ react.createElement('option', { value: 'neutral' }, '中性'),
1155
+ ),
1156
+ react.createElement('select', { className: 'knj-input', style: { flex: 1, borderColor: route.to ? undefined : '#dc2626' }, value: route.to || '', onChange: (e) => this.patchRoute(node.id, i, { to: e.target.value }) },
1157
+ route.to ? null : react.createElement('option', { value: '' }, '选择目标节点…'),
1158
+ targets.map((t) => react.createElement('option', { key: t, value: t }, t)),
1159
+ ),
1160
+ react.createElement('button', { className: 'knj-btn', style: { flex: 'none', padding: '0 8px', height: 26, fontSize: 11 }, title: '删除该去向', onClick: () => this.removeRoute(node.id, i) }, '✕'),
1161
+ );
1162
+ }
1163
+ patchEdge(key, patch) {
1164
+ this.scheduleCommit(); // 条件输入防抖合并
1165
+ const [from, to] = key.split('->');
1166
+ const wf = this.state.wf;
1167
+ wf.edges = wf.edges.map((e) => e.from === from && e.to === to ? { ...e, ...patch } : e);
1168
+ this.setState({ wf });
1169
+ }
1170
+ valueToText(v) {
1171
+ if (v == null) return '';
1172
+ if (Array.isArray(v)) return v.join(',');
1173
+ return String(v);
1174
+ }
1175
+ removeEdge(key) {
1176
+ const [from, to] = key.split('->');
1177
+ const wf = this.state.wf;
1178
+ wf.edges = wf.edges.filter((e) => !(e.from === from && e.to === to));
1179
+ this.pushNow(wf); // 删除后提交,撤销可恢复
1180
+ this.setState({ wf, selectedEdge: null });
1181
+ }
1182
+ addAction(nodeId, hookType) {
1183
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1184
+ if (!node) return;
1185
+ const list = node[hookType] || [];
1186
+ this.patchNode(nodeId, { [hookType]: [...list, { type: 'builtin', name: 'git-checkout', params: { branch: '' }, as: '' }] });
1187
+ }
1188
+ removeAction(nodeId, hookType, index) {
1189
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1190
+ if (!node) return;
1191
+ this.patchNode(nodeId, { [hookType]: (node[hookType] || []).filter((_, i) => i !== index) });
1192
+ }
1193
+ patchAction(nodeId, hookType, index, patch) {
1194
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1195
+ if (!node) return;
1196
+ this.patchNode(nodeId, { [hookType]: (node[hookType] || []).map((a, i) => i === index ? { ...a, ...patch } : a) });
1197
+ }
1198
+ commitParams(nodeId, hookType, index, text) {
1199
+ try {
1200
+ const parsed = text.trim() ? JSON.parse(text) : {};
1201
+ this.patchAction(nodeId, hookType, index, { params: parsed });
1202
+ } catch { /* 忽略,保持原值 */ }
1203
+ }
1204
+ renderAction(node, hookType, index, action) {
1205
+ const isBuiltin = action.type !== 'exec';
1206
+ return react.createElement('div', { key: index, className: 'knj-card', style: { flexDirection: 'column', alignItems: 'stretch', gap: 6 } },
1207
+ react.createElement('div', { className: 'knj-row', style: { marginBottom: 0 } },
1208
+ react.createElement('select', { className: 'knj-input', style: { maxWidth: 108 }, value: isBuiltin ? 'builtin' : 'exec', onChange: (e) => this.patchAction(node.id, hookType, index, e.target.value === 'builtin' ? { type: 'builtin', name: 'git-checkout', params: { branch: '' } } : { type: 'exec', prompt: '', output: {} }) },
1209
+ react.createElement('option', { value: 'builtin' }, '内置动作'),
1210
+ react.createElement('option', { value: 'exec' }, '执行式'),
1211
+ ),
1212
+ isBuiltin ? react.createElement('select', { className: 'knj-input', style: { flex: 1 }, value: action.name || 'git-checkout', onChange: (e) => this.patchAction(node.id, hookType, index, { name: e.target.value }) },
1213
+ BUILTIN_ACTIONS_CLIENT.map((b) => react.createElement('option', { key: b.name, value: b.name }, `${b.label}(${b.name})`)),
1214
+ ) : null,
1215
+ react.createElement('button', { className: 'knj-btn danger', style: { padding: '0 8px', flex: 'none' }, onClick: () => this.removeAction(node.id, hookType, index) }, '×'),
1216
+ ),
1217
+ isBuiltin
1218
+ ? react.createElement('textarea', { key: `${node.id}-${hookType}-${index}-params`, className: 'knj-textarea', style: { minHeight: 50, fontSize: 12 }, placeholder: '参数 JSON,如 {"branch":"main"}', defaultValue: JSON.stringify(action.params || {}), onBlur: (e) => this.commitParams(node.id, hookType, index, e.target.value) })
1219
+ : react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 60 }, placeholder: '自然语言描述(如:根据工单号查 API 拿分支)', value: action.prompt || '', onChange: (e) => this.patchAction(node.id, hookType, index, { prompt: e.target.value }) }),
1220
+ react.createElement('input', { className: 'knj-input', placeholder: 'as 命名(输出进 ctx,可选)', value: action.as || '', onChange: (e) => this.patchAction(node.id, hookType, index, { as: e.target.value }) }),
1221
+ );
1222
+ }
1223
+ renderHookSection(node, hookType, title) {
1224
+ const actions = node[hookType] || [];
1225
+ return react.createElement('div', null,
1226
+ react.createElement('div', { className: 'knj-form-label', style: { marginTop: 12 } }, title),
1227
+ actions.map((a, i) => this.renderAction(node, hookType, i, a)),
1228
+ react.createElement('button', { className: 'knj-btn', style: { width: '100%', borderStyle: 'dashed' }, onClick: () => this.addAction(node.id, hookType) }, '+ 添加动作'),
1229
+ );
1230
+ }
1231
+ /** 生成"可引用的上游输出"文本(供行为 prompt 的 ⓘ hover 与节点字段手册使用) */
1232
+ buildRefsText(node) {
1233
+ const wf = this.state.wf;
1234
+ const refs = wf.nodes
1235
+ .filter((n) => n.type === 'task' && n.id !== node?.id)
1236
+ .map((n) => ({ title: n.title, id: n.id, fields: this.fieldOptionsOf(n) }))
1237
+ .filter((r) => r.fields.length > 0);
1238
+ if (refs.length === 0) return '(暂无其他 task 节点的输出可引用)';
1239
+ return refs.map((r) => `${r.title}(${r.id}):${r.fields.map((f) => '${' + r.id + '.' + f + '}').join('、')}`).join('\n');
1240
+ }
1241
+ fieldOptionsOf(node) {
1242
+ const output = node && node.body ? node.body.output : null;
1243
+ if (!output || output.type !== 'object' || !output.properties) return [];
1244
+ return Object.keys(output.properties);
1245
+ }
1246
+ renderNodeProps(node) {
1247
+ const isTask = node.type === 'task';
1248
+ // 字段区 = 标题 + 一行精简说明(直接可见)+ 输入控件;完整细节在 ⓘ hover 里
1249
+ const fieldLabel = (text, tip, desc) => react.createElement('div', { style: { marginBottom: 4 } },
1250
+ react.createElement('div', { className: 'knj-form-label' }, text,
1251
+ tip ? react.createElement('span', { onMouseEnter: (e) => this.showTip(e, tip), onMouseLeave: () => this.hideTip(), style: { cursor: 'help', color: 'var(--dsw-alias-label-tertiary)', marginLeft: 6, fontSize: 12 } }, 'ⓘ') : null,
1252
+ ),
1253
+ desc ? react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.5, marginBottom: 4 } }, desc) : null,
1254
+ );
1255
+ return react.createElement('div', { className: 'knj-ge-props-inner' },
1256
+ react.createElement('div', { className: 'knj-form-label' }, '节点配置',
1257
+ react.createElement('span', { onClick: () => this.setState({ showNodeGuide: true }), style: { cursor: 'pointer', color: 'var(--dsw-alias-label-tertiary)', marginLeft: 8, fontSize: 12, textDecoration: 'underline' }, title: '节点各字段的配置说明(区别于顶部「工作流配置说明」)' }, '❓ 字段说明'),
1258
+ ),
1259
+ fieldLabel('节点标题(显示名)', '节点在画布和阶段列表里显示的名称,如「需求分析」。仅作展示,不影响引用。', '画布与阶段列表里的显示名'),
1260
+ react.createElement('input', { className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:需求分析', value: node.title || '', onChange: (e) => this.patchNode(node.id, { title: e.target.value }) }),
1261
+ fieldLabel('节点编码(id)', '节点唯一标识:下游用 ${编码.字段} 引用(如 ${analyze.description}),网关用字段值做分支判断。修改后 prompt 里旧的 ${旧编码.字段} 引用需手动更新。', '下游用 ${编码.字段} 引用,如 ${analyze.description}'),
1262
+ react.createElement('input', { className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:analyze', defaultValue: node.id || '', onBlur: (e) => this.patchNodeId(node.id, e.target.value.trim()) }),
1263
+ isTask ? react.createElement('div', null,
1264
+ fieldLabel('行为 prompt(主体)', '本节点让 AI 做什么,写清目标与约束。\n可引用:\n${inputDescription}(任务需求描述)\n${inputTitle}(标题)\n${inputs.参数名}(任务输入)\n\n上游输出引用(${上游编码.字段}):\n' + this.buildRefsText(node) + '\n\n注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。', '让 AI 做什么;可引用 ${inputDescription}、${上游编码.字段}(ⓘ 看完整清单)'),
1265
+ react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 140 }, placeholder: '如:根据需求 ${inputDescription} 输出功能描述与验收标准', value: node.body?.prompt || '', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, prompt: e.target.value } }) }),
1266
+ fieldLabel('执行方式', 'single:一个 AI 完成本阶段,输出单个对象。\nparallel:派多个 AI 并行做同一件事,结果合并为数组(配合「最大执行次数」外的 parallelItems 使用,见输出说明)。', 'single:单个 AI 完成;parallel:多个 AI 并行,结果合并为数组'),
1267
+ react.createElement('select', { className: 'knj-input', value: node.body?.mode || 'single', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, mode: e.target.value } }) },
1268
+ react.createElement('option', { value: 'single' }, 'single'),
1269
+ react.createElement('option', { value: 'parallel' }, 'parallel'),
1270
+ ),
1271
+ fieldLabel('最大执行次数', '本节点在一轮任务里最多执行几次(默认 3:首次 + 最多 2 次重跑)。\n驳回重做 / 循环连线会重复执行节点,超过上限本轮失败,防止死循环。', '默认 3(首次 + 最多 2 次重跑),超限本轮失败防死循环'),
1272
+ react.createElement('input', { className: 'knj-input', type: 'number', min: 1, style: { maxWidth: 120 }, value: node.maxRuns || 3, onChange: (e) => { const v = parseInt(e.target.value, 10); this.patchNode(node.id, { maxRuns: Number.isInteger(v) && v >= 1 ? v : 3 }); } }),
1273
+ this.renderOutputSchema(node),
1274
+ this.renderHookSection(node, 'prehook', 'prehook(执行前动作)'),
1275
+ this.renderHookSection(node, 'posthook', 'posthook(执行后动作)'),
1276
+ ) : node.type === 'human' ? react.createElement('div', null,
1277
+ humanRoutesIncomplete(node) ? react.createElement('div', { style: { fontSize: 12, color: '#dc2626', marginBottom: 8, lineHeight: 1.6 } }, '⚠ 至少需要一个完整去向(标签 + 目标节点),保存会被拦截,画布上该节点显示红色警示。') : null,
1278
+ fieldLabel('展示产物(上游节点 id)', '人工审批时展示哪个上游节点的输出(如 verify),审批人先看产物再决定。', '审批时展示哪个上游节点的输出,如 verify'),
1279
+ react.createElement('input', { className: 'knj-input', placeholder: '如 verify', value: node.displayFrom || '', onChange: (e) => this.patchNode(node.id, { displayFrom: e.target.value }) }),
1280
+ fieldLabel('去向(审批按钮,可多条)', '运行到本节点时暂停等待人工:每个去向是一个按钮,点击后流程走到对应目标。\n每条 = 标签(按钮文字)+ 目标节点 + 色调。填写的意见会注入目标节点。\n旧配置的「通过/驳回」自动兼容为两条去向。', '每个去向 = 一个审批按钮;可附意见注入目标节点'),
1281
+ humanRoutes(node).map((route, i) => this.renderRouteRow(node, route, i)),
1282
+ react.createElement('button', { className: 'knj-btn', style: { alignSelf: 'flex-start', marginTop: 6 }, onClick: () => this.addRoute(node.id) }, '+ 添加去向'),
1283
+ ) : null,
1284
+ react.createElement('div', { className: 'knj-row', style: { marginTop: 12 } },
1285
+ react.createElement('button', { className: 'knj-btn danger', onClick: () => this.removeNode(node.id) }, '删除节点'),
1286
+ ),
1287
+ );
1288
+ }
1289
+ renderOutputSchema(node) {
1290
+ const output = node.body?.output || {};
1291
+ const props = output.properties || {};
1292
+ const fields = Object.keys(props);
1293
+ return react.createElement('div', null,
1294
+ react.createElement('div', { className: 'knj-form-label', style: { marginTop: 10 } }, '节点输出(本阶段产物)',
1295
+ react.createElement('span', { onMouseEnter: (e) => this.showTip(e, '定义本节点完成后输出的字段。\n下游阶段用 ${编码.字段} 引用(如 ${complexity.level}),网关用字段值做分支判断(如 level eq high)。\n布尔 / 数组 / 枚举建议显式指定类型,判断才可靠。\n并行执行(parallel)时结果为数组。'), onMouseLeave: () => this.hideTip(), style: { cursor: 'help', color: 'var(--dsw-alias-label-tertiary)', marginLeft: 6, fontSize: 12 } }, 'ⓘ'),
1296
+ ),
1297
+ react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.5, marginBottom: 4 } }, '本节点的输出字段:下游 ${编码.字段} 引用、网关分支判断;布尔/数组/枚举建议指定类型'),
1298
+ react.createElement('input', {
1299
+ key: 'out-' + node.id + '-' + fields.join(','),
1300
+ className: 'knj-input',
1301
+ placeholder: '字段名,多个用逗号分隔(如 level, reason)',
1302
+ defaultValue: fields.join(', '),
1303
+ onBlur: (e) => this.patchOutputFields(node.id, e.target.value),
1304
+ }),
1305
+ fields.length > 0 ? react.createElement('div', { className: 'knj-form-label', style: { marginTop: 8 } }, `字段定义(${fields.length})`) : null,
1306
+ fields.map((f) => this.renderOutputField(node, f, props[f])),
1307
+ fields.length === 0 ? react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', marginTop: 6, lineHeight: 1.5 } }, '在上方输入字段名(多个用逗号分隔),失焦后即为本节点定义输出字段。') : null,
1308
+ );
1309
+ }
1310
+ renderOutputField(node, field, schema) {
1311
+ const type = schema?.type || '';
1312
+ const desc = schema?.description || '';
1313
+ return react.createElement('div', { key: field, style: { marginBottom: 6 } },
1314
+ react.createElement('div', { className: 'knj-stage-row', style: { gap: 6 } },
1315
+ react.createElement('span', {
1316
+ style: { fontSize: 13, flex: 'none', minWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--dsw-alias-label-primary)' },
1317
+ }, field),
1318
+ react.createElement('select', { className: 'knj-input', style: { flex: 'none', width: 96 }, value: type, onChange: (e) => this.patchOutputFieldType(node.id, field, e.target.value) },
1319
+ react.createElement('option', { value: '' }, '自动'),
1320
+ react.createElement('option', { value: 'string' }, 'string'),
1321
+ react.createElement('option', { value: 'number' }, 'number'),
1322
+ react.createElement('option', { value: 'boolean' }, 'boolean'),
1323
+ react.createElement('option', { value: 'array' }, 'array'),
1324
+ ),
1325
+ type === 'string' ? react.createElement('input', { className: 'knj-input', style: { flex: 1, minWidth: 0 }, placeholder: '枚举(逗号分隔,可选)', defaultValue: (schema?.enum || []).join(','), onBlur: (e) => this.patchOutputFieldEnum(node.id, field, e.target.value) }) : null,
1326
+ ),
1327
+ // 字段说明:直接输入框编辑(含义 / 格式 / 来源,注入 subagent 的输出 schema),不再用弹窗
1328
+ react.createElement('input', { className: 'knj-input', style: { marginTop: 4, fontSize: 12 }, placeholder: '字段说明(含义 / 格式 / 来源,会注入 subagent 的输出 schema)', value: desc, onChange: (e) => this.patchOutputFieldDescription(node.id, field, e.target.value) }),
1329
+ );
1330
+ }
1331
+ showTip(e, text) {
1332
+ this.setState({ tooltip: { text, x: e.clientX, y: e.clientY } });
1333
+ }
1334
+ hideTip() {
1335
+ this.setState({ tooltip: null });
1336
+ }
1337
+ patchOutputFields(nodeId, text) {
1338
+ const fields = String(text || '').split(',').map((s) => s.trim()).filter(Boolean);
1339
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1340
+ if (!node) return;
1341
+ const oldProps = node.body?.output?.properties || {};
1342
+ const properties = {};
1343
+ for (const f of fields) properties[f] = oldProps[f] || {};
1344
+ const output = { type: 'object', properties };
1345
+ this.patchNode(nodeId, { body: { ...node.body, output } });
1346
+ }
1347
+ patchOutputFieldType(nodeId, field, type) {
1348
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1349
+ if (!node) return;
1350
+ const props = { ...(node.body?.output?.properties || {}) };
1351
+ const old = props[field] || {};
1352
+ if (!type) props[field] = {};
1353
+ else props[field] = { ...old, type };
1354
+ this.patchNode(nodeId, { body: { ...node.body, output: { type: 'object', properties: props } } });
1355
+ }
1356
+ patchOutputFieldEnum(nodeId, field, text) {
1357
+ const values = String(text || '').split(',').map((s) => s.trim()).filter(Boolean);
1358
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1359
+ if (!node) return;
1360
+ const props = { ...(node.body?.output?.properties || {}) };
1361
+ const old = props[field] || {};
1362
+ if (values.length) props[field] = { ...old, enum: values };
1363
+ else { const { enum: _e, ...rest } = old; props[field] = rest; }
1364
+ this.patchNode(nodeId, { body: { ...node.body, output: { type: 'object', properties: props } } });
1365
+ }
1366
+ patchOutputFieldDescription(nodeId, field, text) {
1367
+ const node = this.state.wf.nodes.find((n) => n.id === nodeId);
1368
+ if (!node) return;
1369
+ const props = { ...(node.body?.output?.properties || {}) };
1370
+ const old = props[field] || {};
1371
+ if (text.trim()) props[field] = { ...old, description: text.trim() };
1372
+ else { const { description: _d, ...rest } = old; props[field] = rest; }
1373
+ this.patchNode(nodeId, { body: { ...node.body, output: { type: 'object', properties: props } } });
1374
+ }
1375
+ schemaToText(s) {
1376
+ if (!s || !Object.keys(s).length) return '';
1377
+ try { return JSON.stringify(s, null, 2); } catch { return ''; }
1378
+ }
1379
+ commitSchema(id, text) {
1380
+ const node = this.state.wf.nodes.find((n) => n.id === id);
1381
+ if (!node) return;
1382
+ if (!text.trim()) {
1383
+ this.patchNode(id, { body: { ...node.body, output: {} } });
1384
+ this.setState({ schemaError: null });
1385
+ return;
1386
+ }
1387
+ try {
1388
+ const parsed = JSON.parse(text);
1389
+ this.patchNode(id, { body: { ...node.body, output: parsed } });
1390
+ this.setState({ schemaError: null });
1391
+ } catch (err) {
1392
+ this.setState({ schemaError: err instanceof Error ? err.message : String(err) });
1393
+ }
1394
+ }
1395
+ formatSchema(id) {
1396
+ const node = this.state.wf.nodes.find((n) => n.id === id);
1397
+ if (!node) return;
1398
+ const ta = this.schemaRef.current;
1399
+ const text = ta ? ta.value : '';
1400
+ if (!text.trim()) { this.setState({ schemaError: null }); return; }
1401
+ try {
1402
+ const parsed = JSON.parse(text);
1403
+ const formatted = JSON.stringify(parsed, null, 2);
1404
+ if (ta) ta.value = formatted;
1405
+ this.patchNode(id, { body: { ...node.body, output: parsed } });
1406
+ this.setState({ schemaError: null });
1407
+ } catch (err) {
1408
+ this.setState({ schemaError: err instanceof Error ? err.message : String(err) });
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ const NODE_LABEL = { task: '任务节点', 'gateway-xor': '排他网关', 'gateway-and': '并行网关', human: '人工节点' };
1414
+ /** 内置动作库(与 orchestrator.js 的 BUILTIN_ACTIONS 对应,供 UI 下拉选择) */
1415
+ const BUILTIN_ACTIONS_CLIENT = [
1416
+ { name: 'git-pull', label: '拉取代码', params: { branch: '', remote: '' } },
1417
+ { name: 'git-checkout', label: '切换分支', params: { branch: '', remote: 'origin' } },
1418
+ { name: 'git-clone', label: '克隆仓库', params: { repo: '', branch: '', dir: '' } },
1419
+ { name: 'mkdir', label: '创建目录', params: { path: '' } },
1420
+ { name: 'write-product', label: '落盘产物', params: { path: '' } },
1421
+ ];
1422
+
1423
+ function progressPct(t) {
1424
+ const steps = t.stageStates || [];
1425
+ if (!steps.length) return 0;
1426
+ if (t.status === 'success') return 100;
1427
+ const relevant = steps.filter((s) => s.status !== 'skipped');
1428
+ if (!relevant.length) return 0;
1429
+ const done = relevant.filter((s) => s.status === 'done').length;
1430
+ return Math.round((done / relevant.length) * 100);
1431
+ }
1432
+
1433
+ /** 判断字符串是否像文件路径(含扩展名或路径分隔符,且不太长) */
1434
+ function looksLikeFile(s) {
1435
+ if (typeof s !== 'string' || !s.trim()) return false;
1436
+ const v = s.trim();
1437
+ if (v.length > 220) return false;
1438
+ return /\.\w{1,12}$/.test(v) || /[/\\]/.test(v);
1439
+ }
1440
+ /** 从产物(JSON)里收集看起来像文件路径的字符串(去重,限制数量) */
1441
+ function collectFiles(value, out, depth) {
1442
+ out = out || new Set();
1443
+ depth = depth || 0;
1444
+ if (depth > 4 || out.size >= 20) return out;
1445
+ if (value === null || value === undefined) return out;
1446
+ if (typeof value !== 'object') {
1447
+ if (typeof value === 'string' && looksLikeFile(value)) out.add(value.trim());
1448
+ return out;
1449
+ }
1450
+ if (Array.isArray(value)) {
1451
+ for (const v of value) collectFiles(v, out, depth + 1);
1452
+ } else {
1453
+ for (const k of Object.keys(value)) {
1454
+ // 字段名暗示文件列表(changedFiles/affectedFiles/files 等)时,集合其数组元素
1455
+ if (Array.isArray(value[k]) && /(file|path|src|output|artifact)/i.test(k)) {
1456
+ for (const v of value[k]) collectFiles(v, out, depth + 1);
1457
+ } else collectFiles(value[k], out, depth + 1);
1458
+ }
1459
+ }
1460
+ return out;
1461
+ }
1462
+
1463
+ // ---------------------------------------------------------------------------
1464
+ // 插件入口:
1465
+ // 1. 左侧栏底部 sidebar.footer.action(list 槽)→「➕ 新建任务」按钮
1466
+ // 2. 右侧栏(better-sidebar)→「📋 开发任务」「⚙️ 工作流」两个 tab
1467
+ // 3. better-sidebar 缺失时自动降级(保留左侧按钮,不注册 tab)
1468
+ // 不再使用对话尾部按钮(conversation.chat.turnTail)。
1469
+ // ---------------------------------------------------------------------------
1470
+ // ---------------------------------------------------------------------------
1471
+ // 新建任务模态(完整表单)+ 中央工作台(子 tab)+ 打开函数
1472
+ // ---------------------------------------------------------------------------
1473
+ let newTaskRoot = null;
1474
+ function openNewTaskModal() {
1475
+ ensureStyle();
1476
+ if (newTaskRoot) { newTaskRoot.style.display = 'flex'; return; }
1477
+ newTaskRoot = document.createElement('div');
1478
+ document.body.appendChild(newTaskRoot);
1479
+ react_dom_client.createRoot(newTaskRoot).render(react.createElement(NewTaskModal, {
1480
+ onClose: () => { if (newTaskRoot) newTaskRoot.style.display = 'none'; },
1481
+ }));
1482
+ }
1483
+
1484
+ class NewTaskModal extends react.Component {
1485
+ constructor(props) {
1486
+ super(props);
1487
+ this.state = { description: '', title: '', workflowId: '', cwd: '', cwdOptions: [], inputs: {}, workflows: [], busy: false, error: null };
1488
+ }
1489
+ componentDidMount() { this.load(); }
1490
+ async load() {
1491
+ try {
1492
+ const w = await api('/workflows');
1493
+ const workflows = w.workflows || [];
1494
+ // 工作目录下拉选项:来自 DSH 工作区列表(path 为 canonical 目录,title 为 basename)
1495
+ const cwdOptions = [];
1496
+ try {
1497
+ // 动态取 workspaces 服务(避免 apply 时机缓存失败);items 为空时主动 refresh 拉取
1498
+ const ws = _ctx?.get?.('workspaces') || _workspaces;
1499
+ if (ws && typeof ws.refresh === 'function' && !(ws.list?.getSnapshot?.()?.items || []).length) {
1500
+ await ws.refresh();
1501
+ }
1502
+ const items = ws?.list?.getSnapshot?.()?.items || [];
1503
+ console.log('[knj-workflow] workspaces:', ws ? 'ok' : 'null', 'items:', items.length, 'sample:', items[0] ? (items[0].getSnapshot ? JSON.stringify(items[0].getSnapshot().view) : JSON.stringify(items[0])) : null);
1504
+ for (const item of items) {
1505
+ // 防御两种结构:Workspace 实例(有 getSnapshot().view)或 raw workspace view(自身即带 path)
1506
+ const view = item?.getSnapshot?.()?.view || item || {};
1507
+ const path = view.path || view.cwd || '';
1508
+ const title = view.title || view.name || (path ? path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '');
1509
+ console.log('[knj-workflow] cwd item:', 'hasGetSnapshot=' + (typeof item?.getSnapshot === 'function'), 'path=' + path, 'title=' + (title || ''));
1510
+ if (path) cwdOptions.push({ path, title: title || path });
1511
+ }
1512
+ } catch (e) { console.warn('[knj-workflow] 加载工作目录失败:', e); }
1513
+ console.log('[knj-workflow] cwdOptions built:', JSON.stringify(cwdOptions));
1514
+ this.setState({ workflows, workflowId: workflows[0]?.id || '', cwdOptions, error: null });
1515
+ } catch (e) { this.setState({ error: e.message }); }
1516
+ }
1517
+ selectedWorkflow() { return this.state.workflows.find((w) => w.id === this.state.workflowId); }
1518
+ render() {
1519
+ const { description, title, workflowId, cwd, cwdOptions, inputs, workflows, busy, error } = this.state;
1520
+ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1521
+ const wf = this.selectedWorkflow();
1522
+ const wfInputs = (wf && Array.isArray(wf.inputs)) ? wf.inputs : [];
1523
+ return react.createElement('div', { className: 'knj-overlay' },
1524
+ react.createElement('div', { className: 'knj-panel', style: { width: 'min(560px, 94vw)' } },
1525
+ react.createElement('div', { className: 'knj-head' },
1526
+ react.createElement('h2', null, '新建开发任务'),
1527
+ react.createElement('button', { className: 'knj-close', onClick: this.props.onClose }, '关闭'),
1528
+ ),
1529
+ react.createElement('div', { className: 'knj-body' },
1530
+ error ? react.createElement('div', { className: 'knj-err' }, '加载失败:' + error) : null,
1531
+ react.createElement('div', { className: 'knj-form-section' },
1532
+ react.createElement('div', { className: 'knj-form-label' }, '需求描述(必填)'),
1533
+ react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 80 }, placeholder: '描述你要做什么,如:用户可从文件列表勾选多个文件并批量下载', value: description, onChange: (e) => this.setState({ description: e.target.value }) }),
1534
+ ),
1535
+ react.createElement('div', { className: 'knj-form-section' },
1536
+ react.createElement('div', { className: 'knj-form-label' }, '标题(创建后自动总结,可改)'),
1537
+ react.createElement('input', { className: 'knj-input', placeholder: '留空则创建后自动总结', value: title, onChange: (e) => this.setState({ title: e.target.value }) }),
1538
+ ),
1539
+ react.createElement('div', { className: 'knj-form-section' },
1540
+ react.createElement('div', { className: 'knj-form-label' }, '工作流'),
1541
+ react.createElement('select', { className: 'knj-input', value: workflowId, onChange: (e) => this.setState({ workflowId: e.target.value }) },
1542
+ workflows.map((w) => react.createElement('option', { key: w.id, value: w.id }, `${w.name}(${(w.nodes || []).filter((n) => n.type === 'task').length} 阶段)`)),
1543
+ ),
1544
+ ),
1545
+ react.createElement('div', { className: 'knj-form-section' },
1546
+ react.createElement('div', { className: 'knj-form-label' }, '工作目录(可选)'),
1547
+ react.createElement('select', { className: 'knj-input', value: cwd, onChange: (e) => this.setState({ cwd: e.target.value }) }, react.createElement('option', { value: '' }, '默认(当前工作区)'), cwdOptions.map((o) => react.createElement('option', { key: o.path, value: o.path, title: o.path }, o.title))),
1548
+ ),
1549
+ wfInputs.length > 0 ? react.createElement('div', { className: 'knj-form-section' },
1550
+ react.createElement('div', { className: 'knj-form-label' }, '任务输入参数'),
1551
+ wfInputs.map((inp) => react.createElement('input', { key: inp.name, className: 'knj-input', style: { marginBottom: 8 }, placeholder: `${inp.label || inp.name}${inp.required ? '(必填)' : ''}`, value: inputs[inp.name] || '', onChange: (e) => this.setState({ inputs: { ...inputs, [inp.name]: e.target.value } }) })),
1552
+ ) : null,
1553
+ react.createElement('div', { className: 'knj-row', style: { justifyContent: 'flex-end' } },
1554
+ react.createElement('button', { className: 'knj-btn', onClick: this.props.onClose }, '取消'),
1555
+ react.createElement('button', { className: 'knj-btn primary', disabled: busy || !description.trim() || !workflowId, onClick: () => this.submit() }, '创建并启动'),
1556
+ ),
1557
+ ),
1558
+ ),
1559
+ );
1560
+ }
1561
+ async submit() {
1562
+ const { description, title, workflowId, cwd, inputs } = this.state;
1563
+ const wf = this.selectedWorkflow();
1564
+ const wfInputs = (wf && Array.isArray(wf.inputs)) ? wf.inputs : [];
1565
+ this.setState({ busy: true });
1566
+ try {
1567
+ const body = { title: title.trim() || description.trim().slice(0, 50), workflowId };
1568
+ body.description = description.trim();
1569
+ if (cwd.trim()) body.cwd = cwd.trim();
1570
+ const filled = {};
1571
+ wfInputs.forEach((inp) => { const v = inputs[inp.name]; if (v != null && v !== '') filled[inp.name] = v; });
1572
+ if (Object.keys(filled).length) body.inputs = filled;
1573
+ await api('/tasks', { method: 'POST', body });
1574
+ toast('任务已创建并启动');
1575
+ this.props.onClose();
1576
+ openDevBoard();
1577
+ } catch (e) { toast('创建失败:' + e.message); }
1578
+ this.setState({ busy: false });
1579
+ }
1580
+ }
1581
+
1582
+ /** 中央「开发任务」页签内容:子 tab 切「任务 / 工作流」,铺满主区 */
1583
+ class WorkbenchView extends react.Component {
1584
+ constructor(props) {
1585
+ super(props);
1586
+ this.state = { tab: 'tasks' };
1587
+ }
1588
+ render() {
1589
+ const { tab } = this.state;
1590
+ return react.createElement('div', { className: 'knj-workbench' },
1591
+ react.createElement('div', { className: 'knj-wb-head' },
1592
+ react.createElement('div', { className: 'knj-wb-tabs' },
1593
+ react.createElement('button', { className: 'knj-wb-tab' + (tab === 'tasks' ? ' on' : ''), onClick: () => this.setState({ tab: 'tasks' }) }, '任务'),
1594
+ react.createElement('button', { className: 'knj-wb-tab' + (tab === 'workflows' ? ' on' : ''), onClick: () => this.setState({ tab: 'workflows' }) }, '工作流'),
1595
+ ),
1596
+ ),
1597
+ tab === 'tasks'
1598
+ ? react.createElement(SidebarTasksTab, {})
1599
+ : react.createElement(SidebarWorkflowsTab, {}),
1600
+ );
1601
+ }
1602
+ }
1603
+
1604
+ let devBoardRoot = null;
1605
+ let _closeDevBoard = null; // 收起开发任务弹窗(跳转子会话前先收起,避免盖住)
1606
+ function openDevBoard() {
1607
+ ensureStyle();
1608
+ if (devBoardRoot) { devBoardRoot.style.display = 'flex'; return; }
1609
+ devBoardRoot = document.createElement('div');
1610
+ document.body.appendChild(devBoardRoot);
1611
+ _closeDevBoard = () => { if (devBoardRoot) devBoardRoot.style.display = 'none'; };
1612
+ react_dom_client.createRoot(devBoardRoot).render(react.createElement(DevBoardOverlay, {
1613
+ onClose: _closeDevBoard,
1614
+ }));
1615
+ }
1616
+
1617
+ class DevBoardOverlay extends react.Component {
1618
+ render() {
1619
+ return react.createElement('div', { className: 'knj-overlay' },
1620
+ react.createElement('div', { className: 'knj-panel', style: { width: 'min(1680px, 97vw)', height: '93vh', maxHeight: '93vh' } },
1621
+ react.createElement('div', { className: 'knj-head' },
1622
+ react.createElement('h2', null, '开发任务'),
1623
+ react.createElement('button', { className: 'knj-close', onClick: this.props.onClose }, '关闭'),
1624
+ ),
1625
+ react.createElement('div', { className: 'knj-body', style: { padding: 0 } },
1626
+ react.createElement(WorkbenchView, {}),
1627
+ ),
1628
+ ),
1629
+ );
1630
+ }
1631
+ }
1632
+
1633
+ const inject = ['slots'];
1634
+ function apply(ctx) {
1635
+ const slots = ctx.get('slots');
1636
+ if (!slots) return;
1637
+ _sessions = ctx.get('sessions') || null; // 注入 sessions 服务,供「执行记录」跳转
1638
+ _workspaces = ctx.get('workspaces') || null; // 注入 workspaces 服务,供「新建任务」工作目录下拉
1639
+ _ctx = ctx;
1640
+ ensureStyle(); // 中央页签与模态共用样式,注册前先注入
1641
+
1642
+ // 1) 菜单项注册到 knj.menu.item(由 dsh-knj-menu 菜单管理器统一渲染成常驻/折叠 + 📌固定)
1643
+ // 用 slots.inject 等待 knj.menu.item 声明(跨插件加载顺序无关),再注册。
1644
+ slots.inject('knj.menu.item', () => slots.register(
1645
+ { name: 'knj.menu.item', id: 'knj-new-task', label: '新建任务', order: 0, defaultPinned: true, locale: 'zh' },
1646
+ (props) => react.createElement('button', {
1647
+ className: 'knj-newtask',
1648
+ title: '新建开发任务(绑定工作流并启动)',
1649
+ onClick: () => openNewTaskModal(),
1650
+ },
1651
+ react.createElement('svg', { className: 'knj-ico', width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg' },
1652
+ react.createElement('path', { d: 'M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z', fill: 'currentColor' }),
1653
+ ),
1654
+ react.createElement('span', { style: { whiteSpace: 'nowrap', overflow: 'hidden' } }, '新建任务'),
1655
+ )));
1656
+ slots.inject('knj.menu.item', () => slots.register(
1657
+ { name: 'knj.menu.item', id: 'knj-dev-board', label: '开发任务', order: 10, defaultPinned: false, locale: 'zh' },
1658
+ (props) => react.createElement('button', {
1659
+ className: 'knj-newtask',
1660
+ title: '打开开发任务看板',
1661
+ onClick: () => openDevBoard(),
1662
+ },
1663
+ icon('list'),
1664
+ react.createElement('span', { style: { whiteSpace: 'nowrap', overflow: 'hidden' } }, '开发任务'),
1665
+ )));
1666
+
1667
+ // 「开发任务」已迁移到左侧工具栏入口 + overlay 看板(见 knj-dev-board / openDevBoard),
1668
+ // 不再注册 conversation.view 页签。
1669
+ }
1670
+
1671
+ /** 「开发任务」页签:任务列表(卡片 + 分页 + 进度) */
1672
+ class SidebarTasksTab extends react.Component {
1673
+ constructor(props) {
1674
+ super(props);
1675
+ this.state = { tasks: [], error: null, detail: null, page: 1 };
1676
+ }
1677
+ componentDidMount() { this.refresh(); this._timer = setInterval(() => this.refresh(), 3000); }
1678
+ componentWillUnmount() { clearInterval(this._timer); }
1679
+ async refresh() {
1680
+ try {
1681
+ const t = await api('/tasks');
1682
+ this.setState({ tasks: t.tasks || [], error: null });
1683
+ } catch (e) { this.setState({ error: e.message }); }
1684
+ }
1685
+ stageInfo(t) {
1686
+ const steps = t.stageStates || [];
1687
+ const relevant = steps.filter((s) => s.status !== 'skipped');
1688
+ const done = relevant.filter((s) => s.status === 'done').length;
1689
+ const skipped = steps.length - relevant.length;
1690
+ if (t.status === 'success') return `已完成 · ${done}/${relevant.length} 阶段${skipped ? `(跳过 ${skipped})` : ''}`;
1691
+ if (t.status === 'failed' || t.status === 'cancelled') return `${t.currentStage ? '失败于「' + t.currentStage + '」' : '已中断'} · ${done}/${relevant.length} 阶段`;
1692
+ if (t.status === 'waiting-human') return `待人工处理 · ${done}/${relevant.length} 阶段`;
1693
+ if (t.currentStage) return `阶段 ${Math.min(done + 1, relevant.length)}/${relevant.length} · ${t.currentStage}`;
1694
+ return `共 ${relevant.length} 个阶段`;
1695
+ }
1696
+ render() {
1697
+ const { tasks, error, detail } = this.state;
1698
+ if (detail) {
1699
+ return react.createElement(SidebarTaskDetail, {
1700
+ task: detail,
1701
+ onBack: () => this.setState({ detail: null }),
1702
+ onChanged: async () => { await this.refresh(); this.loadDetail(detail.id); },
1703
+ });
1704
+ }
1705
+ const PAGE_SIZE = 8;
1706
+ const totalPages = Math.max(1, Math.ceil(tasks.length / PAGE_SIZE));
1707
+ const page = Math.min(this.state.page, totalPages);
1708
+ const pageTasks = tasks.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
1709
+ return react.createElement('div', null,
1710
+ error ? react.createElement('div', { className: 'knj-err' }, '加载失败:' + error) : null,
1711
+ react.createElement('div', { className: 'knj-row', style: { justifyContent: 'space-between', marginBottom: 14 } },
1712
+ react.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, `共 ${tasks.length} 个任务`),
1713
+ react.createElement('div', { className: 'knj-row', style: { gap: 8 } },
1714
+ react.createElement('button', { className: 'knj-btn', title: '刷新', onClick: () => this.refresh() }, icon('refresh'), '刷新'),
1715
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => openNewTaskModal() }, icon('plus'), '新建任务'),
1716
+ ),
1717
+ ),
1718
+ tasks.length === 0
1719
+ ? react.createElement('div', { className: 'knj-empty' }, '暂无任务,点击右上角「新建任务」开始')
1720
+ : pageTasks.map((t) => react.createElement('div', { key: t.id, className: 'knj-item-card' },
1721
+ react.createElement('div', { className: 'knj-item-head' },
1722
+ react.createElement('div', { className: 'knj-item-title', onClick: () => this.loadDetail(t.id) }, t.title),
1723
+ react.createElement('div', { className: 'knj-row', style: { gap: 6 } },
1724
+ react.createElement('span', { className: 'knj-badge', style: { background: (STATUS_META[t.status] || STATUS_META.pending).color } }, (STATUS_META[t.status] || STATUS_META.pending).label),
1725
+ react.createElement('button', { className: 'knj-btn', style: { padding: '0 8px', height: 26, fontSize: 12 }, title: '删除任务', onClick: (e) => { e.stopPropagation(); this.deleteTask(t.id); } }, icon('x')),
1726
+ ),
1727
+ ),
1728
+ react.createElement('div', { className: 'knj-item-stage' }, this.stageInfo(t)),
1729
+ react.createElement('div', { className: 'knj-item-foot' },
1730
+ react.createElement('div', { className: 'knj-progress' }, react.createElement('div', { style: { width: progressPct(t) + '%' } })),
1731
+ react.createElement('span', { className: 'knj-item-pct' }, `${progressPct(t)}%`),
1732
+ ),
1733
+ )),
1734
+ totalPages > 1 ? react.createElement('div', { className: 'knj-pager' },
1735
+ react.createElement('button', { disabled: page <= 1, onClick: () => this.setState({ page: page - 1 }) }, '‹'),
1736
+ react.createElement('span', { className: 'pg' }, `${page} / ${totalPages}`),
1737
+ react.createElement('button', { disabled: page >= totalPages, onClick: () => this.setState({ page: page + 1 }) }, '›'),
1738
+ ) : null,
1739
+ );
1740
+ }
1741
+ async deleteTask(id) {
1742
+ if (!window.confirm('确定删除该任务?其执行记录与产物将一并删除,不可恢复。')) return;
1743
+ try {
1744
+ await api('/tasks/' + id, { method: 'DELETE' });
1745
+ toast('任务已删除');
1746
+ this.refresh();
1747
+ } catch (e) { toast(e.message); }
1748
+ }
1749
+ async loadDetail(id) {
1750
+ try { const { task } = await api('/tasks/' + id); this.setState({ detail: task }); }
1751
+ catch (e) { toast(e.message); }
1752
+ }
1753
+ }
1754
+
1755
+ /** 右侧栏「工作流」tab:模板列表 + 编辑(复用 GraphEditor) */
1756
+ class SidebarWorkflowsTab extends react.Component {
1757
+ constructor(props) {
1758
+ super(props);
1759
+ this.state = { workflows: [], error: null, editing: null, page: 1 };
1760
+ }
1761
+ componentDidMount() { this.refresh(); }
1762
+ async refresh() {
1763
+ try {
1764
+ const w = await api('/workflows');
1765
+ this.setState({ workflows: w.workflows || [], error: null });
1766
+ } catch (e) { this.setState({ error: e.message }); }
1767
+ }
1768
+ render() {
1769
+ const { workflows, error, editing, page } = this.state;
1770
+ const PAGE_SIZE = 8;
1771
+ const totalPages = Math.max(1, Math.ceil((workflows || []).length / PAGE_SIZE));
1772
+ const curPage = Math.min(page || 1, totalPages);
1773
+ const pageWorkflows = (workflows || []).slice((curPage - 1) * PAGE_SIZE, curPage * PAGE_SIZE);
1774
+ if (editing) {
1775
+ return react.createElement(GraphEditor, {
1776
+ workflow: editing,
1777
+ onBack: () => this.setState({ editing: null }),
1778
+ onSaved: async () => { await this.refresh(); this.setState({ editing: null }); },
1779
+ });
1780
+ }
1781
+ return react.createElement('div', null,
1782
+ error ? react.createElement('div', { className: 'knj-err' }, '加载失败:' + error) : null,
1783
+ react.createElement('div', { className: 'knj-row', style: { justifyContent: 'space-between', marginBottom: 14 } },
1784
+ react.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, `共 ${workflows.length} 个工作流`),
1785
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => {
1786
+ this.setState({ editing: { id: 'wf-' + Date.now().toString(36), name: '', description: '', schemaVersion: 2, inputs: [], nodes: [{ id: 'start', type: 'start' }, { id: 'end', type: 'end' }], edges: [] } });
1787
+ } }, icon('plus'), '新建工作流'),
1788
+ ),
1789
+ (workflows || []).length === 0
1790
+ ? react.createElement('div', { className: 'knj-empty' }, '暂无工作流,点击右上角「新建工作流」')
1791
+ : pageWorkflows.map((w) => react.createElement('div', { key: w.id, className: 'knj-item-card' },
1792
+ react.createElement('div', { className: 'knj-item-head' },
1793
+ react.createElement('div', { className: 'knj-item-title' }, w.name),
1794
+ react.createElement('div', { className: 'knj-row', style: { gap: 6 } },
1795
+ react.createElement('button', { className: 'knj-btn', onClick: () => this.setState({ editing: w }) }, '编辑'),
1796
+ react.createElement('button', { className: 'knj-btn danger', onClick: async () => {
1797
+ if (!window.confirm('确定删除工作流「' + w.name + '」?已有任务不受影响(使用快照)。')) return;
1798
+ try { await api('/workflows/' + w.id, { method: 'DELETE' }); this.refresh(); } catch (e) { toast(e.message); }
1799
+ } }, '删除'),
1800
+ ),
1801
+ ),
1802
+ react.createElement('div', { className: 'knj-item-stage' }, `${w.id} · ${(w.nodes || []).filter((n) => n.type === 'task').length} 个阶段${w.description ? ' · ' + w.description : ''}`),
1803
+ )),
1804
+ totalPages > 1 ? react.createElement('div', { className: 'knj-pager' },
1805
+ react.createElement('button', { disabled: curPage <= 1, onClick: () => this.setState({ page: curPage - 1 }) }, '‹'),
1806
+ react.createElement('span', { className: 'pg' }, `${curPage} / ${totalPages}`),
1807
+ react.createElement('button', { disabled: curPage >= totalPages, onClick: () => this.setState({ page: curPage + 1 }) }, '›'),
1808
+ ) : null,
1809
+ );
1810
+ }
1811
+ }
1812
+
1813
+ /** 右侧栏任务详情(横向步骤条 + 重跑/继续),复用 TaskDetail 渲染逻辑 */
1814
+ class SidebarTaskDetail extends react.Component {
1815
+ constructor(props) {
1816
+ super(props);
1817
+ this.state = { results: null, rejectFeedback: '', expanded: null, fileView: null };
1818
+ }
1819
+ componentDidMount() { this.loadResults(); this.preloadCatalog(); }
1820
+ /** 预加载 parent 的子会话 catalog:让「查看」点击时直接 open、无需每次 await 慢 RPC */
1821
+ async preloadCatalog() {
1822
+ const { task } = this.props;
1823
+ if (!_sessions || !task.parentSessionId) return;
1824
+ try { await _sessions.refreshSubagents(task.parentSessionId); } catch {}
1825
+ }
1826
+ async loadResults() {
1827
+ try {
1828
+ const r = await api('/tasks/' + this.props.task.id + '/results');
1829
+ this.setState({ results: r.results });
1830
+ } catch { this.setState({ results: null }); }
1831
+ }
1832
+ /** 刷新详情:重新拉产物 + 让父级重取任务数据 */
1833
+ async refresh() {
1834
+ await this.loadResults();
1835
+ if (this.props.onChanged) this.props.onChanged();
1836
+ }
1837
+ /** 某节点的产物(results.json 里按节点 id 存) */
1838
+ resultFor(stageId) {
1839
+ const results = this.state.results;
1840
+ if (!results || typeof results !== 'object') return null;
1841
+ return results[stageId];
1842
+ }
1843
+ toggle(stageId) {
1844
+ this.setState((s) => ({ expanded: s.expanded === stageId ? null : stageId }));
1845
+ }
1846
+ duration(s) {
1847
+ if (!s.startedAt || !s.finishedAt) return '';
1848
+ const ms = new Date(s.finishedAt) - new Date(s.startedAt);
1849
+ if (ms < 0) return '';
1850
+ const sec = Math.round(ms / 1000);
1851
+ if (sec < 60) return `${sec}s`;
1852
+ const m = Math.floor(sec / 60), r = sec % 60;
1853
+ return `${m}m${r}s`;
1854
+ }
1855
+ /** 流程节点卡片:状态圆点 + 标题 + 耗时,点击展开产物与执行记录(整合,不再平铺三个区块) */
1856
+ renderNodeCard(s, i) {
1857
+ const meta = STAGE_META[s.status] || STAGE_META.pending;
1858
+ const isRunning = s.status === 'running';
1859
+ const dur = this.duration(s);
1860
+ const result = this.resultFor(s.id);
1861
+ const sessionIds = s.sessionIds || [];
1862
+ const expanded = this.state.expanded === s.id;
1863
+ const hasDetail = result != null || sessionIds.length > 0 || s.error;
1864
+ const statusIcon = s.status === 'done' ? icon('check')
1865
+ : isRunning ? icon('clock')
1866
+ : s.status === 'skipped' ? '–'
1867
+ : s.status === 'failed' ? icon('x')
1868
+ : (i + 1);
1869
+ return react.createElement('div', { key: s.id, className: 'knj-node-card' },
1870
+ react.createElement('div', { className: 'knj-node-head', onClick: () => hasDetail && this.toggle(s.id) },
1871
+ react.createElement('div', { className: 'knj-node-dot', style: { background: meta.color, ...(isRunning ? { boxShadow: '0 0 0 4px rgba(37,99,235,.2)' } : {}) } }, statusIcon),
1872
+ react.createElement('div', { style: { flex: 1, minWidth: 0 } },
1873
+ react.createElement('div', { className: 'knj-node-title' }, s.title),
1874
+ react.createElement('div', { className: 'knj-node-sub' }, dur ? `${dur} · ${meta.label}` : meta.label),
1875
+ ),
1876
+ s.status === 'failed'
1877
+ ? react.createElement('button', { className: 'knj-btn danger', style: { flexShrink: 0 }, onClick: (e) => { e.stopPropagation(); this.rerunStage(s.id); } }, '重跑')
1878
+ : null,
1879
+ hasDetail ? react.createElement('span', { style: { flexShrink: 0, color: 'var(--dsw-alias-label-tertiary)', display: 'inline-flex' } }, icon(expanded ? 'chevron-down' : 'chevron-right')) : null,
1880
+ ),
1881
+ expanded ? react.createElement('div', { className: 'knj-node-body' },
1882
+ s.error ? react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-state-error-primary)', wordBreak: 'break-all' } }, s.error) : null,
1883
+ result != null ? react.createElement('div', null,
1884
+ react.createElement('div', { className: 'knj-node-sec' }, '产物'),
1885
+ this.renderFiles(s.id, result),
1886
+ react.createElement('pre', null, JSON.stringify(result, null, 2)),
1887
+ ) : null,
1888
+ this.state.fileView && this.state.fileView.stageId === s.id ? react.createElement('div', null,
1889
+ react.createElement('div', { className: 'knj-node-sec', style: { display: 'flex', alignItems: 'center', gap: 8 } },
1890
+ react.createElement('span', null, '文件内容'),
1891
+ react.createElement('span', { style: { fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, this.state.fileView.path),
1892
+ ),
1893
+ react.createElement('pre', { style: { maxHeight: 320 } }, this.state.fileView.content),
1894
+ ) : null,
1895
+ sessionIds.length > 0 ? react.createElement('div', null,
1896
+ react.createElement('div', { className: 'knj-node-sec' }, '执行记录'),
1897
+ sessionIds.map((sid) => react.createElement('div', { key: sid, className: 'knj-row', style: { marginTop: 6, gap: 8 } },
1898
+ react.createElement('code', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', flex: 1, wordBreak: 'break-all' } }, sid),
1899
+ react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0 }, onClick: () => this.viewSubagent(sid) }, '查看'),
1900
+ )),
1901
+ ) : null,
1902
+ ) : null,
1903
+ );
1904
+ }
1905
+ /** 从产物里识别文件路径并渲染为可点击的「查看」列表 */
1906
+ renderFiles(stageId, result) {
1907
+ const files = [...collectFiles(result)];
1908
+ if (!files.length) return null;
1909
+ return react.createElement('div', null,
1910
+ react.createElement('div', { className: 'knj-node-sec' }, '文件'),
1911
+ files.map((f) => react.createElement('div', { key: f, className: 'knj-row', style: { marginTop: 6, gap: 8 } },
1912
+ react.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: f }, f),
1913
+ react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0 }, onClick: () => this.viewFile(stageId, f) }, '查看'),
1914
+ )),
1915
+ );
1916
+ }
1917
+ /** 读取并展示任务工作目录下的文件内容(相对 cwd 解析,Host 端限制在 cwd 内) */
1918
+ async viewFile(stageId, path) {
1919
+ const { task } = this.props;
1920
+ try {
1921
+ const r = await api('/tasks/' + task.id + '/file?path=' + encodeURIComponent(path));
1922
+ this.setState({ fileView: { stageId, path: r.path, content: r.content } });
1923
+ } catch (e) { toast('读取失败:' + e.message); }
1924
+ }
1925
+ renderHumanCard(task) {
1926
+ const humanNode = (task.workflowSnapshot?.nodes || []).find((n) => n.id === task.humanState?.humanId);
1927
+ const routes = humanRoutes(humanNode);
1928
+ const displayFrom = humanNode?.displayFrom;
1929
+ const results = this.state.results || task.humanState?.results || {};
1930
+ const display = displayFrom ? results[displayFrom] : null;
1931
+ const btnStyle = (route) => {
1932
+ const map = { success: 'var(--dsw-static-green-500)', danger: 'var(--dsw-static-red-500)', warning: 'var(--dsw-static-amber-500)', info: 'var(--dsw-static-blue-600)', neutral: 'var(--dsw-alias-label-secondary)' };
1933
+ const c = map[route.tone] || map.info;
1934
+ return { flex: 1, borderColor: c, color: c };
1935
+ };
1936
+ return react.createElement('div', { className: 'knj-card', style: { flexDirection: 'column', alignItems: 'stretch', borderColor: '#7c5cff', background: '#f4f1ff', marginBottom: 12 } },
1937
+ react.createElement('div', { className: 'knj-row' },
1938
+ react.createElement('span', { style: { fontWeight: 600, color: '#7c5cff', fontSize: 13 } }, '等待人工处理'),
1939
+ react.createElement('span', { className: 'knj-badge', style: { background: '#7c5cff' } }, '待审批'),
1940
+ ),
1941
+ react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginTop: 8, lineHeight: 1.6 } }, routes.length > 0 ? '任务已运行到人工审批节点。选择一个去向继续(可附意见)。' : '任务已运行到人工审批节点(未配置去向)。'),
1942
+ display
1943
+ ? react.createElement('pre', { style: { fontSize: 12, lineHeight: 1.5, overflow: 'auto', maxHeight: 200, margin: '8px 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary)', background: '#fff', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 10 } }, JSON.stringify(display, null, 2))
1944
+ : react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginTop: 8 } }, '(未配置展示产物或产物为空)'),
1945
+ react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 56, marginTop: 10 }, placeholder: '意见(可选,去向目标节点会看到)', value: this.state.rejectFeedback, onChange: (e) => this.setState({ rejectFeedback: e.target.value }) }),
1946
+ react.createElement('div', { className: 'knj-row', style: { marginTop: 12, gap: 10 } },
1947
+ routes.map((route) => react.createElement('button', { key: route.label + ':' + route.to, className: 'knj-btn', style: btnStyle(route), onClick: () => this.decide(route.label) }, route.label || '(未命名去向)')),
1948
+ ),
1949
+ );
1950
+ }
1951
+ render() {
1952
+ const { task, onBack } = this.props;
1953
+ const steps = task.stageStates || [];
1954
+ const pct = progressPct(task);
1955
+ const statusMeta = STATUS_META[task.status] || STATUS_META.pending;
1956
+ const relevant = steps.filter((s) => s.status !== 'skipped');
1957
+ const doneCount = relevant.filter((s) => s.status === 'done').length;
1958
+ return react.createElement('div', { style: { padding: 12 } },
1959
+ // 头部:返回 + 标题 + 刷新 + 状态
1960
+ react.createElement('div', { className: 'knj-row' },
1961
+ react.createElement('button', { className: 'knj-btn', onClick: onBack }, '←'),
1962
+ react.createElement('div', { style: { flex: 1, minWidth: 0 } },
1963
+ react.createElement('div', { className: 'title', style: { fontSize: 14, fontWeight: 600 } }, task.title),
1964
+ react.createElement('div', { className: 'sub' }, task.id),
1965
+ ),
1966
+ react.createElement('button', { className: 'knj-btn', title: '刷新', onClick: () => this.refresh() }, icon('refresh'), '刷新'),
1967
+ react.createElement('span', { className: 'knj-badge', style: { background: statusMeta.color } }, statusMeta.label),
1968
+ ),
1969
+ // 整体进度条 + 摘要
1970
+ react.createElement('div', { className: 'knj-progress', style: { width: '100%', marginTop: 12 } },
1971
+ react.createElement('div', { style: { width: pct + '%' } }),
1972
+ ),
1973
+ react.createElement('div', { className: 'sub', style: { marginTop: 4 } }, `进度 ${pct}% · 完成 ${doneCount}/${relevant.length} 阶段${steps.length - relevant.length ? `(跳过 ${steps.length - relevant.length})` : ''}`),
1974
+ // 人工审批卡片
1975
+ task.status === 'waiting-human' ? this.renderHumanCard(task) : null,
1976
+ // 流程节点卡片(纵向,一眼看全局;点击节点展开产物/执行记录)
1977
+ react.createElement('div', { className: 'knj-form-section', style: { marginTop: 12 } },
1978
+ react.createElement('div', { className: 'knj-form-label' }, `流程进度(${steps.length})`),
1979
+ steps.map((s, i) => this.renderNodeCard(s, i)),
1980
+ ),
1981
+ // 操作按钮
1982
+ task.status === 'pending'
1983
+ ? react.createElement('div', { className: 'knj-row' },
1984
+ react.createElement('button', { className: 'knj-btn primary', onClick: () => this.startTask() }, icon('play'), '启动'),
1985
+ )
1986
+ : task.status === 'running'
1987
+ ? react.createElement('div', { className: 'knj-row' },
1988
+ react.createElement('button', { className: 'knj-btn danger', onClick: () => this.cancelTask() }, icon('x'), '取消'),
1989
+ )
1990
+ : (task.status === 'failed' || task.status === 'cancelled')
1991
+ ? react.createElement('div', { className: 'knj-row', style: { gap: 8 } },
1992
+ react.createElement('button', { className: 'knj-btn primary', style: { flex: 1 }, title: '从断点继续(跳过已完成节点)', onClick: () => this.resume('resume') }, icon('play'), '续跑'),
1993
+ react.createElement('button', { className: 'knj-btn', style: { flex: 1 }, title: '从头重新执行所有节点', onClick: () => this.resume('rerun') }, icon('repeat'), '重跑'),
1994
+ )
1995
+ : null,
1996
+ task.error ? react.createElement('div', { className: 'knj-err' }, '错误:' + task.error) : null,
1997
+ );
1998
+ }
1999
+ /** 把某个节点 subagent 会话跳回 DSH 原生会话查看(只读查看执行细节) */
2000
+ async viewSubagent(childId) {
2001
+ if (!_sessions) { toast('sessions 服务不可用'); return; }
2002
+ try {
2003
+ // 先收起开发任务弹窗,再跳子会话,避免弹窗盖住子会话
2004
+ _closeDevBoard?.();
2005
+ // catalog 已在 preloadCatalog 后台加载,这里直接 open(select 内部会 navigationAddress 解析)。
2006
+ // 若 catalog 尚未就绪导致 open 抛错,回退刷新一次再跳。
2007
+ try {
2008
+ _sessions.open(childId);
2009
+ } catch (firstError) {
2010
+ const { task } = this.props;
2011
+ if (task.parentSessionId) await _sessions.refreshSubagents(task.parentSessionId);
2012
+ _sessions.open(childId);
2013
+ }
2014
+ } catch (e) { toast('跳转失败:' + (e && e.message ? e.message : e)); }
2015
+ }
2016
+ async decide(decision) {
2017
+ try {
2018
+ const feedback = (this.state.rejectFeedback || '').trim();
2019
+ await api('/tasks/' + this.props.task.id + '/decide', { method: 'POST', body: { decision, ...(feedback ? { feedback } : {}) } });
2020
+ toast('已选择:' + decision);
2021
+ setTimeout(() => this.props.onChanged(), 500);
2022
+ } catch (e) { toast(e.message); }
2023
+ }
2024
+ async rerunStage(stageId) {
2025
+ try {
2026
+ await api('/tasks/' + this.props.task.id + '/rerun-stage', { method: 'POST', body: { stageId } });
2027
+ toast('已重跑阶段:' + stageId);
2028
+ setTimeout(() => this.props.onChanged(), 500);
2029
+ } catch (e) { toast(e.message); }
2030
+ }
2031
+ async resume(mode = 'resume') {
2032
+ try {
2033
+ await api('/tasks/' + this.props.task.id + '/resume', { method: 'POST', body: { mode } });
2034
+ toast(mode === 'rerun' ? '任务已从头重跑' : '任务已从断点继续');
2035
+ setTimeout(() => this.props.onChanged(), 500);
2036
+ } catch (e) { toast(e.message); }
2037
+ }
2038
+ async startTask() {
2039
+ try {
2040
+ await api('/tasks/' + this.props.task.id + '/start', { method: 'POST', body: {} });
2041
+ toast('任务已启动');
2042
+ setTimeout(() => this.props.onChanged(), 500);
2043
+ } catch (e) { toast(e.message); }
2044
+ }
2045
+ async cancelTask() {
2046
+ try {
2047
+ await api('/tasks/' + this.props.task.id + '/cancel', { method: 'POST', body: {} });
2048
+ toast('已请求取消');
2049
+ setTimeout(() => this.props.onChanged(), 500);
2050
+ } catch (e) { toast(e.message); }
2051
+ }
2052
+ }
2053
+
2054
+ exports.apply = apply;
2055
+ exports.inject = inject;
2056
+ return module.exports;
2057
+ }
2058
+ });