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/README.md +77 -0
- package/cordis.patch.yml +18 -0
- package/lib/client.js +2058 -0
- package/lib/graph.js +197 -0
- package/lib/graph.test.js +274 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +1001 -0
- package/lib/orchestrator.js +322 -0
- package/lib/orchestrator.test.js +682 -0
- package/package.json +60 -0
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
|
+
});
|