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 = `
- 共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} + 共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位
-
- - ${edges.map(e => { - const a = pos[e.from], b = pos[e.to]; - if (!a || !b) return ''; - const mx = (a.x + b.x) / 2; - const act = idMap[e.from].status === 'done'; - return ``; - }).join('')} - ${tasks.map(t => { - const p = pos[t.id]; - const col = colors[t.status]; - return ` - - #${t.id} ${esc(t.title).slice(0,10)} - ${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置'+t.depends_on.length : ''} - `; - }).join('')} -
-
💡 节点点击查看详情;前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`; +
+ + + + ${edges.map(e => { + const a = dagState.pos[e.from], b = dagState.pos[e.to]; + if (!a || !b) return ''; + const mx = (a.x + b.x) / 2; + const act = idMap[e.from].status === 'done'; + return ``; + }).join('')} + ${tasks.map(t => { + const p = dagState.pos[t.id]; + const col = colors[t.status]; + return ` + + #${t.id} ${esc(t.title).slice(0, 10)} + ${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''} + `; + }).join('')} + +
+
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`; + 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}