diff --git a/docs/v2/dag_canvas.png b/docs/v2/dag_canvas.png
new file mode 100644
index 0000000..9869b67
Binary files /dev/null and b/docs/v2/dag_canvas.png differ
diff --git a/static/app.js b/static/app.js
index 5dd63fe..5fe6f52 100644
--- a/static/app.js
+++ b/static/app.js
@@ -287,14 +287,10 @@ async function moveTask(tid, status) {
toast('状态已更新', 'ok'); refreshProj();
}
-/* --- DAG 工作流 --- */
-function renderDag() {
- const {tasks} = projCtx;
- const idMap = {};
- tasks.forEach(t => { idMap[t.id] = t; });
- const edges = [];
- tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from:d, to:t.id}); }));
+/* --- DAG 工作流(可交互画布:拖节点 / 滚轮缩放 / 拖画布平移) --- */
+let dagState = null; // {pid, pos:{id:{x,y}}, scale, tx, ty, drag:null, moved:false}
+function dagLayout(tasks, idMap) {
// 拓扑分层:最长路径
const layer = {};
const visit = id => {
@@ -308,43 +304,231 @@ function renderDag() {
tasks.forEach(t => visit(t.id));
const layers = {};
tasks.forEach(t => { (layers[layer[t.id]] = layers[layer[t.id]] || []).push(t.id); });
-
const W = 260, H = 110, PAD = 60;
const width = Math.max(2, Object.keys(layers).length) * W + PAD;
const height = Math.max(...tasks.map(t => layer[t.id] === undefined ? 0 : layer[t.id]), 0) * H + PAD + 60;
const pos = {};
Object.entries(layers).forEach(([l, ids]) => {
const x = PAD + parseInt(l) * W;
- const step = Math.min(H - 60, Math.max(70, (height - 2*PAD) / (ids.length + 1)));
+ const step = Math.min(H - 60, Math.max(70, (height - 2 * PAD) / (ids.length + 1)));
ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; });
});
+ return {width, height, pos};
+}
+
+function dagSize() {
+ const {tasks} = projCtx;
+ const idMap = {};
+ tasks.forEach(t => { idMap[t.id] = t; });
+ return dagLayout(tasks, idMap);
+}
+
+function saveDagState() {
+ if (!dagState) return;
+ try {
+ const pos = {};
+ Object.entries(dagState.pos).forEach(([k, v]) => { pos[k] = {x: Math.round(v.x), y: Math.round(v.y)}; });
+ localStorage.setItem('aw_dag_pos_' + dagState.pid, JSON.stringify(pos));
+ localStorage.setItem('aw_dag_view_' + dagState.pid,
+ JSON.stringify({scale: dagState.scale, tx: Math.round(dagState.tx), ty: Math.round(dagState.ty)}));
+ } catch (e) {}
+}
+
+function dagApplyView() {
+ const s = dagState;
+ const svg = $('#dag-svg');
+ if (!svg) return;
+ svg.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`;
+ svg.style.transformOrigin = '0 0';
+}
+
+function drawDag() {
+ const {tasks} = projCtx;
+ const idMap = {};
+ tasks.forEach(t => { idMap[t.id] = t; });
+ const edges = [];
+ tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from: d, to: t.id}); }));
+ const {width, height} = dagSize();
const colors = {todo:'#2a3550', running:'#173b6b', review:'#4a3a10', done:'#0f3d2e', rejected:'#4a1c1c', failed:'#3d1c2e', cancelled:'#333'};
const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done')));
- $('#tab-body').innerHTML = `
+ const body = $('#tab-body');
+ if (!body) return;
+ body.innerHTML = `
-
+
-
- 💡 节点点击查看详情;前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`;
+
+
+
+ 💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`;
+ dagApplyView();
+ bindDagEvents();
+}
+
+function bindDagEvents() {
+ const box = $('#dag-box');
+ const svg = $('#dag-svg');
+ if (!box || !svg) return;
+ const s = dagState;
+ let down = null; // {type:'node'|'pan', id?, sx, sy, ox, oy}
+
+ box.addEventListener('mousedown', e => {
+ if (e.button !== 0) return;
+ const nodeEl = e.target.closest('.dag-node');
+ if (nodeEl) {
+ const id = Number(nodeEl.dataset.id);
+ const p = s.pos[id];
+ down = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false};
+ nodeEl.style.cursor = 'grabbing';
+ svg.style.cursor = 'grabbing';
+ } else {
+ down = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
+ svg.style.cursor = 'grabbing';
+ }
+ e.preventDefault();
+ });
+
+ window.addEventListener('mousemove', e => {
+ if (!down) return;
+ const dx = e.clientX - down.sx, dy = e.clientY - down.sy;
+ if (!down.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
+ down.moved = true;
+ if (down.type === 'node') {
+ const p = s.pos[down.id];
+ if (p) { p.x = down.ox + dx / s.scale; p.y = down.oy + dy / s.scale; }
+ } else {
+ s.tx = down.ox + dx;
+ s.ty = down.oy + dy;
+ }
+ dagApplyView();
+ if (down.type === 'node') {
+ // 轻量更新:节点坐标 + 关联连线
+ const svgEl = $('#dag-svg');
+ if (!svgEl) return;
+ svgEl.querySelectorAll('.dag-node').forEach(g => {
+ const nid = Number(g.dataset.id);
+ const pp = s.pos[nid];
+ if (!pp) return;
+ g.querySelector('rect').setAttribute('x', pp.x - 55);
+ g.querySelector('rect').setAttribute('y', pp.y - 24);
+ const ts = g.querySelectorAll('text');
+ ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2);
+ ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15);
+ });
+ // 以拖动节点为中心更新相关连线
+ const touched = new Set([down.id]);
+ projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => {
+ if (d === down.id || t.id === down.id) { touched.add(d); touched.add(t.id); }
+ }));
+ touched.forEach(tid => {
+ const a = s.pos[tid];
+ if (!a) return;
+ projCtx.tasks.forEach(t => {
+ if (t.id !== tid) return;
+ (t.depends_on || []).forEach(d => {
+ const b = s.pos[d];
+ if (!b) return;
+ const path = svgEl.querySelector(`path[data-e="${d}-${tid}"]`);
+ if (!path) return;
+ const mx = (b.x + a.x) / 2;
+ path.setAttribute('d', `M${b.x + 55},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 55},${a.y}`);
+ });
+ });
+ });
+ }
+ });
+
+ window.addEventListener('mouseup', () => {
+ if (!down) return;
+ const wasNode = down.type === 'node';
+ if (wasNode) {
+ const nodeEl = svg.querySelector('.dag-node[data-id="' + down.id + '"]');
+ if (nodeEl) nodeEl.style.cursor = 'grab';
+ }
+ svg.style.cursor = '';
+ if (down.moved) {
+ dagState.suppressClick = true;
+ setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
+ saveDagState();
+ }
+ down = null;
+ });
+
+ // 滚轮缩放(以鼠标为锚点)
+ box.addEventListener('wheel', e => {
+ e.preventDefault();
+ const rect = box.getBoundingClientRect();
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
+ const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
+ const ns = Math.min(3, Math.max(0.25, s.scale * factor));
+ // 保持鼠标指向的内容坐标不动
+ const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale;
+ s.scale = ns;
+ s.tx = mx - cx * ns;
+ s.ty = my - cy * ns;
+ dagApplyView();
+ saveDagState();
+ }, {passive: false});
+
+ // 双击空白复位视图
+ box.addEventListener('dblclick', e => {
+ if (e.target.closest('.dag-node')) return;
+ s.scale = 1; s.tx = 0; s.ty = 0;
+ dagApplyView();
+ saveDagState();
+ });
+
+ // 点击节点打开详情(拖动超过阈值则不触发)
+ box.addEventListener('click', e => {
+ const nodeEl = e.target.closest('.dag-node');
+ if (!nodeEl) return;
+ if (dagState.suppressClick) { dagState.suppressClick = false; return; }
+ openTaskDetail(Number(nodeEl.dataset.id));
+ });
+}
+
+function renderDag() {
+ const {tasks, pid} = projCtx;
+ const idMap = {};
+ tasks.forEach(t => { idMap[t.id] = t; });
+ const {pos} = dagLayout(tasks, idMap);
+ // 恢复已保存布局 / 视图
+ let savedPos = null, savedView = null;
+ try {
+ savedPos = JSON.parse(localStorage.getItem('aw_dag_pos_' + pid) || 'null');
+ savedView = JSON.parse(localStorage.getItem('aw_dag_view_' + pid) || 'null');
+ } catch (e) {}
+ if (savedPos) Object.entries(savedPos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
+ dagState = {
+ pid, pos,
+ scale: savedView?.scale || 1,
+ tx: savedView?.tx || 0,
+ ty: savedView?.ty || 0,
+ drag: null, moved: false,
+ };
+ const box = $('#tab-body');
+ box.innerHTML = '';
+ drawDag();
}
async function runWorkflow() {
diff --git a/static/style.css b/static/style.css
index d49103e..fd52878 100644
--- a/static/style.css
+++ b/static/style.css
@@ -131,7 +131,8 @@ tr:hover td{background:var(--panel2)}
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
-.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;overflow:auto}
+.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;height:520px;position:relative;touch-action:none;user-select:none;cursor:grab}
+.dag-box svg{display:block;transition:transform .05s linear}
.dag-node{fill:var(--panel2);stroke:var(--border);stroke-width:1.5;rx:10;cursor:pointer}
.dag-node:hover{stroke:var(--accent)}
.dag-label{font-size:12px;fill:var(--text);text-anchor:middle}