From 9c45a143b9da510ff39541af778486933a793692 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 12 Aug 2026 23:44:20 +0800 Subject: [PATCH] =?UTF-8?q?DAG=E7=94=BB=E5=B8=83=EF=BC=9A=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=B8=85=E6=99=B0=20+=20=E6=97=A0=E8=BE=B9=E7=94=BB?= =?UTF-8?q?=E5=B8=83=20+=20=E5=88=A0=E9=99=A4=E5=8C=BA=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 文字看不清修复:.dag-node hover stroke 继承到文字导致白色小字被描边盖糊 → label/sub 加 stroke:none,字号加大(13/11px)加粗 2. 无边画布:bg rect 扩大至±4000且与画布同色 + svg overflow:visible,大幅平移缩放不再露出边缘/裁剪节点 3. 删除区与回收站: - 画布左下角🗑️删除区,节点拖入即软删除(deleted=1) - 点击删除区打开回收站:恢复/彻底删除 - 后端 tasks 加 deleted/deleted_at 列,全查询过滤,trash/restore/hard-delete API,看板删除改软删 - 修复画布重建后事件失效:box监听每次重绘重绑、window监听只绑一次且拖拽状态提升为模块级(dagDrag) --- app.py | 81 ++++++++++++--- db.py | 4 + engine.py | 2 +- static/app.js | 254 ++++++++++++++++++++++++++++++++--------------- static/style.css | 9 +- 5 files changed, 252 insertions(+), 98 deletions(-) diff --git a/app.py b/app.py index 91ba3d1..7326564 100644 --- a/app.py +++ b/app.py @@ -166,8 +166,8 @@ def projects(): return jsonify({'ok': True, 'id': pid}) rows = db.q('SELECT * FROM projects ORDER BY id DESC') for r in rows: - r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=?', (r['id'],))[0]['c'] - r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done"', + r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (r['id'],))[0]['c'] + r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0', (r['id'],))[0]['c'] return jsonify({'ok': True, 'data': rows}) @@ -327,9 +327,9 @@ def tasks(): return jsonify({'ok': True, 'id': tid}) pid = request.args.get('project_id') if pid: - rows = db.q('SELECT * FROM tasks WHERE project_id=? ORDER BY id DESC', (int(pid),)) + rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id DESC', (int(pid),)) else: - rows = db.q('SELECT * FROM tasks ORDER BY id DESC LIMIT 200') + rows = db.q('SELECT * FROM tasks WHERE deleted=0 ORDER BY id DESC LIMIT 200') return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]}) @@ -348,10 +348,14 @@ def task_detail(tid): (t['worker_id'],), one=True) return jsonify({'ok': True, 'data': t}) if request.method == 'DELETE': - db.w('DELETE FROM task_logs WHERE task_id=?', (tid,)) - db.w('DELETE FROM cost_records WHERE task_id=?', (tid,)) - db.w('DELETE FROM tasks WHERE id=?', (tid,)) - return jsonify({'ok': True}) + # 软删除(进回收站);?hard=1 物理删除 + if request.args.get('hard') == '1': + db.w('DELETE FROM task_logs WHERE task_id=?', (tid,)) + db.w('DELETE FROM cost_records WHERE task_id=?', (tid,)) + db.w('DELETE FROM tasks WHERE id=?', (tid,)) + return jsonify({'ok': True}) + db.w('UPDATE tasks SET deleted=1, deleted_at=? WHERE id=?', (db.now(), tid)) + return jsonify({'ok': True, 'trashed': True}) d = request.get_json(force=True) # 只允许在非运行中修改基础字段 if t['status'] == 'running': @@ -372,12 +376,59 @@ def task_detail(tid): return jsonify({'ok': True}) +@app.route('/api/tasks/trash') +@require_auth +def tasks_trash(): + """回收站:已软删除的任务列表""" + pid = request.args.get('project_id') + if pid: + rows = db.q('SELECT * FROM tasks WHERE deleted=1 AND project_id=? ORDER BY deleted_at DESC', + (int(pid),)) + else: + rows = db.q('SELECT * FROM tasks WHERE deleted=1 ORDER BY deleted_at DESC LIMIT 200') + return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]}) + + +@app.route('/api/tasks//trash', methods=['POST']) +@require_auth +def task_trash(tid): + """软删除:移入回收站""" + t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True) + if not t: + return jsonify({'ok': False, 'error': '任务不存在'}), 404 + if t['status'] == 'running': + return jsonify({'ok': False, 'error': '任务执行中,禁止删除'}), 400 + db.w('UPDATE tasks SET deleted=1, deleted_at=? WHERE id=?', (db.now(), tid)) + db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)', + (tid, 'warn', '🗑️ 任务已移入回收站', db.now())) + enterprise.audit(enterprise.current_actor(), 'task.trash', f'task#{tid}', + f'「{t["title"]}」移入回收站', request.remote_addr or '') + return jsonify({'ok': True}) + + +@app.route('/api/tasks//restore', methods=['POST']) +@require_auth +def task_restore(tid): + """从回收站恢复任务""" + t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True) + if not t: + return jsonify({'ok': False, 'error': '任务不存在'}), 404 + db.w('UPDATE tasks SET deleted=0, deleted_at=NULL WHERE id=?', (tid,)) + db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)', + (tid, 'info', '♻️ 任务已从回收站恢复', db.now())) + enterprise.audit(enterprise.current_actor(), 'task.restore', f'task#{tid}', + f'「{t["title"]}」恢复', request.remote_addr or '') + return jsonify({'ok': True}) + + @app.route('/api/tasks//run', methods=['POST']) @require_auth def task_run(tid): t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True) if not t: return jsonify({'ok': False, 'error': '任务不存在'}), 404 + if t['deleted']: + return jsonify({'ok': False, 'error': '任务已在回收站,请先恢复'}), 400 if t['status'] == 'running': return jsonify({'ok': False, 'error': '任务已在执行中'}), 400 ok, blockers = engine.check_dependencies(t) @@ -477,8 +528,8 @@ def stats(): 'by_status': {}, 'recent': [], 'daily_cost': []} out['projects'] = db.q('SELECT COUNT(*) c FROM projects')[0]['c'] out['workers'] = db.q('SELECT COUNT(*) c FROM workers')[0]['c'] - out['tasks'] = db.q('SELECT COUNT(*) c FROM tasks')[0]['c'] - for r in db.q('SELECT status, COUNT(*) c FROM tasks GROUP BY status'): + out['tasks'] = db.q('SELECT COUNT(*) c FROM tasks WHERE deleted=0')[0]['c'] + for r in db.q('SELECT status, COUNT(*) c FROM tasks WHERE deleted=0 GROUP BY status'): out['by_status'][r['status']] = r['c'] c = db.q('SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens ' 'FROM cost_records')[0] @@ -486,14 +537,14 @@ def stats(): # 一次通过率:done 且 rejection_count=0 done = out['by_status'].get('done', 0) - clean = db.q('SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0')[0]['c'] + clean = db.q('SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0 AND deleted=0')[0]['c'] out['one_pass_rate'] = round(clean / done * 100, 1) if done else None - out['rework_count'] = db.q('SELECT COALESCE(SUM(rejection_count),0) c FROM tasks')[0]['c'] + out['rework_count'] = db.q('SELECT COALESCE(SUM(rejection_count),0) c FROM tasks WHERE deleted=0')[0]['c'] out['recent'] = db.q( 'SELECT t.id, t.title, t.status, t.project_id, p.name AS project_name, t.updated_at ' 'FROM tasks t LEFT JOIN projects p ON p.id=t.project_id ' - 'ORDER BY t.updated_at DESC LIMIT 10') + 'WHERE t.deleted=0 ORDER BY t.updated_at DESC LIMIT 10') # 近 7 天成本 import datetime @@ -522,7 +573,7 @@ def logs(): @require_auth def workflow_run(pid): """执行整个工作流:跑所有就绪(无未完成前置)任务""" - rows = db.q('SELECT * FROM tasks WHERE project_id=? AND status IN ("todo","failed")', (pid,)) + rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 AND status IN ("todo","failed")', (pid,)) started, blocked = [], [] for t in rows: ok, blockers = engine.check_dependencies(t) @@ -538,7 +589,7 @@ def workflow_run(pid): @require_auth def project_dag(pid): """DAG 图数据:节点 + 边""" - rows = db.q('SELECT * FROM tasks WHERE project_id=? ORDER BY id', (pid,)) + rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id', (pid,)) nodes, edges, id_map = [], [], {} for t in rows: node = db.serialize_task(t) diff --git a/db.py b/db.py index 056d472..a4c49d3 100644 --- a/db.py +++ b/db.py @@ -282,6 +282,10 @@ def _migrate(): if 'depends_on' not in cols: conn.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'") conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_depends ON tasks(depends_on)') + if 'deleted' not in cols: + conn.execute('ALTER TABLE tasks ADD COLUMN deleted INTEGER DEFAULT 0') + conn.execute('ALTER TABLE tasks ADD COLUMN deleted_at INTEGER') + conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)') conn.commit() conn.close() diff --git a/engine.py b/engine.py index 7268a09..aec4db9 100644 --- a/engine.py +++ b/engine.py @@ -153,7 +153,7 @@ def _build_messages(task, worker): def _trigger_downstream(task): """DAG:任务完成后自动触发所有就绪的下游任务""" - rows = db.q('SELECT * FROM tasks WHERE status IN ("todo","failed")') + rows = db.q('SELECT * FROM tasks WHERE deleted=0 AND status IN ("todo","failed")') triggered = [] for t in rows: deps = _deps(t) diff --git a/static/app.js b/static/app.js index 338c37a..faef2dc 100644 --- a/static/app.js +++ b/static/app.js @@ -360,13 +360,14 @@ function drawDag() { }; body.innerHTML = `
- 共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位 + 共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位 / 拖节点到左下角🗑️删除
- +
🗑️ 删除区0
+ - + ${edges.map(e => { const a = dagState.pos[e.from], b = dagState.pos[e.to]; if (!a || !b) return ''; @@ -388,97 +389,34 @@ function drawDag() {
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`; dagApplyView(); bindDagEvents(); + dagTrashCount(); } +let dagBound = false; // window 级监听只绑定一次 +let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问) function bindDagEvents() { const box = $('#dag-box'); - const svg = $('#dag-svg'); - if (!box || !svg) return; + if (!box) return; const s = dagState; - let down = null; // {type:'node'|'pan', id?, sx, sy, ox, oy} + const svgEl = () => $('#dag-svg'); + // box 级监听:每次 DOM 重建后重新绑定(box 是新元素) 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}; + dagDrag = {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'; + const sv = svgEl(); if (sv) sv.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'; + dagDrag = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false}; + const sv = svgEl(); if (sv) sv.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 - 70); - 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 + 70},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 70},${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(); @@ -486,7 +424,6 @@ function bindDagEvents() { 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; @@ -510,6 +447,165 @@ function bindDagEvents() { if (dagState.suppressClick) { dagState.suppressClick = false; return; } openTaskDetail(Number(nodeEl.dataset.id)); }); + + // window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建) + if (dagBound) return; + dagBound = true; + window.addEventListener('mousemove', e => { + if (!dagDrag) return; + const st = dagState; + if (!st) return; + const dx = e.clientX - dagDrag.sx, dy = e.clientY - dagDrag.sy; + if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return; + dagDrag.moved = true; + const trashEl = $('#dag-trash'); + if (trashEl) { + const tb = trashEl.getBoundingClientRect(); + trashEl.classList.toggle('hover', + e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom); + } + if (dagDrag.type === 'node') { + const p = st.pos[dagDrag.id]; + if (p) { p.x = dagDrag.ox + dx / st.scale; p.y = dagDrag.oy + dy / st.scale; } + } else { + st.tx = dagDrag.ox + dx; + st.ty = dagDrag.oy + dy; + } + dagApplyView(); + if (dagDrag.type === 'node') { + const svgEl2 = $('#dag-svg'); + if (!svgEl2) return; + svgEl2.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 - 70); + 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([dagDrag.id]); + projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => { + if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); } + })); + touched.forEach(tid => { + const a = st.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 = svgEl2.querySelector(`path[data-e="${d}-${tid}"]`); + if (!path) return; + const mx = (b.x + a.x) / 2; + path.setAttribute('d', `M${b.x + 70},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 70},${a.y}`); + }); + }); + }); + } + }); + + window.addEventListener('mouseup', e => { + if (!dagDrag) return; + const wasNode = dagDrag.type === 'node'; + const trashEl = $('#dag-trash'); + let dropped = false; + if (wasNode && dagDrag.moved && trashEl) { + const tb = trashEl.getBoundingClientRect(); + if (e.clientX >= tb.left && e.clientX <= tb.right && e.clientY >= tb.top && e.clientY <= tb.bottom) { + dropped = true; + dagTrashNode(dagDrag.id); + } + } + const sv = $('#dag-svg'); + if (wasNode) { + const nodeEl = sv ? sv.querySelector('.dag-node[data-id="' + dagDrag.id + '"]') : null; + if (nodeEl) nodeEl.style.cursor = 'grab'; + } + if (sv) sv.style.cursor = ''; + if (trashEl) trashEl.classList.remove('hover'); + if (dagDrag.moved && !dropped) { + dagState.suppressClick = true; + setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400); + saveDagState(); + } + dagDrag = null; + }); +} + +/* --- DAG 回收站:软删除 / 恢复 / 彻底删除 --- */ +async function dagTrashCount() { + try { + const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`); + const el = $('#dag-trash-cnt'); + if (el) el.textContent = r.data.length; + } catch (e) {} +} + +async function dagTrashNode(taskId) { + try { + const t = projCtx.tasks.find(x => x.id === taskId); + await api(`/api/tasks/${taskId}/trash`, {method: 'POST'}); + toast(`🗑️ 任务「${t?.title || taskId}」已移入回收站`, 'ok'); + // 重新拉取项目数据并重绘画布 + const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data; + projCtx.tasks = tasks; + const idMap = {}; + tasks.forEach(t => { idMap[t.id] = t; }); + const {pos} = dagLayout(tasks, idMap); + Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; }); + dagState.pos = pos; + drawDag(); + } catch (e) { toast('删除失败:' + e.message, 'err'); } +} + +async function openDagTrash() { + const r = await api(`/api/tasks/trash?project_id=${projCtx.pid}`); + const rows = r.data; + openModal(` +

🗑️ 回收站 ${rows.length} 个已删除任务

+
被删除的任务都在这里,可恢复或彻底删除(不可恢复)。
+ + ${rows.map(t => ` + + + + + + + `).join('') || ''} +
ID标题状态删除时间操作
#${t.id}${esc(t.title)}${STATUS[t.status]?.label || t.status}${fmtTime(t.deleted_at)} +
回收站是空的
+ `); +} + +async function dagRestoreTask(taskId) { + try { + await api(`/api/tasks/${taskId}/restore`, {method: 'POST'}); + toast('♻️ 已恢复', 'ok'); + const tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data; + projCtx.tasks = tasks; + const idMap = {}; + tasks.forEach(t => { idMap[t.id] = t; }); + const {pos} = dagLayout(tasks, idMap); + Object.entries(dagState.pos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; }); + dagState.pos = pos; + drawDag(); + closeModal(); + } catch (e) { toast('恢复失败:' + e.message, 'err'); } +} + +async function dagHardDelete(taskId) { + if (!confirm('彻底删除后不可恢复,确定?')) return; + try { + await api(`/api/tasks/${taskId}?hard=1`, {method: 'DELETE'}); + toast('已彻底删除', 'ok'); + closeModal(); + openDagTrash(); + dagTrashCount(); + } catch (e) { toast('删除失败:' + e.message, 'err'); } } function renderDag() { @@ -741,9 +837,9 @@ async function reopenTask(tid) { toast('任务已重新打开', 'ok'); closeModal(); refreshProj(); } async function delTask(tid) { - if (!confirm('确认删除该任务及其日志/成本记录?')) return; + if (!confirm('删除后任务将移入回收站(DAG 画布左下角🗑️可恢复),确定?')) return; await api(`/api/tasks/${tid}`, {method:'DELETE'}); - toast('已删除', 'ok'); closeModal(); refreshProj(); + toast('已移入回收站', 'ok'); closeModal(); refreshProj(); } /* ---------- AI 规划 WBS ---------- */ diff --git a/static/style.css b/static/style.css index 1448fb8..080f581 100644 --- a/static/style.css +++ b/static/style.css @@ -132,14 +132,17 @@ tr:hover td{background:var(--panel2)} .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;overflow:hidden;height:520px;position:relative;touch-action:none;user-select:none;cursor:grab} -.dag-box svg{display:block;transition:transform .05s linear} +.dag-box svg{display:block;transition:transform .05s linear;overflow:visible} .dag-node{stroke:var(--border);stroke-width:1.5;rx:10;cursor:grab} .dag-node:hover{stroke:var(--accent)} .dag-node rect{stroke-width:1.5;rx:10} -.dag-label{font-size:12px;fill:#ffffff;text-anchor:middle;pointer-events:none} -.dag-sub{font-size:10px;fill:rgba(255,255,255,.78);text-anchor:middle;pointer-events:none} +.dag-label{font-size:13px;font-weight:600;fill:#ffffff;stroke:none;text-anchor:middle;pointer-events:none} +.dag-sub{font-size:11px;fill:rgba(255,255,255,.82);stroke:none;text-anchor:middle;pointer-events:none} .dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)} .dag-edge.act{stroke:var(--accent)} +.dag-trash{position:absolute;left:14px;bottom:14px;background:rgba(255,107,107,.1);border:1.5px dashed var(--danger);color:var(--danger);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;z-index:5;display:flex;align-items:center;gap:6px;backdrop-filter:blur(2px)} +.dag-trash:hover,.dag-trash.hover{background:rgba(255,107,107,.25);border-style:solid} +.dag-trash .cnt{background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:11px} .alert-row{border-left:3px solid var(--border);padding:12px 14px;margin-bottom:8px;background:var(--panel2);border-radius:0 10px 10px 0;cursor:pointer} .alert-row.unread{border-left-color:var(--warn)} .alert-row.critical{border-left-color:var(--danger)}