From 78ccca61c39d492fb8eee65ce271013637a8537a Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 13 Aug 2026 11:31:40 +0800 Subject: [PATCH] =?UTF-8?q?DAG=E7=94=BB=E5=B8=83=E8=BF=9E=E7=BA=BF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=EF=BC=9A=E6=96=B0=E5=BB=BA/=E9=87=8D?= =?UTF-8?q?=E8=BF=9E/=E5=88=A0=E9=99=A4=20+=20=E7=8E=AF=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连线操作三件套: * 新建:拖节点右侧⚪输出口到目标节点(幽灵线预览 + 合法/非法高亮) * 重连:单击连线选中 → 操作条「🔗重连」→ 点击新目标节点(自动移除旧边) * 删除:双击连线 / 悬停中点✕按钮 / 选中后 Delete 键 / 操作条删除按钮 - 单击连线选中高亮 + 顶部操作条(重连/删除/取消),点击空白取消选中 - 前端防环校验(DFS)+ 非法目标标红,后端 PUT/POST depends_on 全面校验: 依赖存在性、非自依赖、全图环检测(返回环路径) - 节点新增左右连接端口(悬停/连线模式显示);拖节点时连线与端口同步更新 - 修复:控件条点击不再误触发取消选中/视图复位 --- app.py | 55 ++++++++++ static/app.js | 274 +++++++++++++++++++++++++++++++++++++++++++---- static/style.css | 22 +++- 3 files changed, 330 insertions(+), 21 deletions(-) diff --git a/app.py b/app.py index 7326564..3ede7d9 100644 --- a/app.py +++ b/app.py @@ -35,6 +35,54 @@ def auth_enabled(): return bool(config.AUTH_PASSWORD) +def _dag_cycle_check(project_id, task_id, new_deps): + """校验给 task_id 设置 new_deps 后项目 DAG 是否成环,返回环路径或 None""" + rows = db.q('SELECT id, depends_on FROM tasks WHERE project_id=? AND deleted=0', (project_id,)) + deps = {} + for r in rows: + try: + deps[r['id']] = _json.loads(r['depends_on'] or '[]') + except Exception: + deps[r['id']] = [] + deps[task_id] = [x for x in (new_deps or []) if x in deps] + visiting, visited, path = set(), set(), [] + def dfs(n): + if n in visiting: + return path[path.index(n):] + [n] + if n in visited: + return None + visiting.add(n) + path.append(n) + for d in deps.get(n, []): + r = dfs(d) + if r: + return r + visiting.remove(n) + path.pop() + visited.add(n) + return None + for n in list(deps): + r = dfs(n) + if r: + return r + return None + + +def _validate_depends_on(project_id, task_id, new_deps): + """依赖校验:存在性 / 非自身 / 无环。返回 (ok, error_message_or_cycle_path)""" + new_deps = new_deps or [] + valid_ids = {r['id'] for r in db.q('SELECT id FROM tasks WHERE project_id=? AND deleted=0', (project_id,))} + bad = [x for x in new_deps if x not in valid_ids] + if bad: + return False, f'依赖任务不存在(已删除或不属于本项目):{bad}' + if task_id in new_deps: + return False, '任务不能依赖自身' + cycle = _dag_cycle_check(project_id, task_id, new_deps) + if cycle: + return False, '⚠️ 会形成依赖环:' + ' → '.join(f'#{x}' for x in cycle) + return True, '' + + @app.route('/api/login', methods=['POST']) def login(): data = request.get_json(force=True) @@ -315,6 +363,10 @@ def providers(): def tasks(): if request.method == 'POST': d = request.get_json(force=True) + if d.get('depends_on'): + ok, err = _validate_depends_on(d.get('project_id'), None, d['depends_on']) + if not ok: + return jsonify({'ok': False, 'error': err}), 400 tid = db.w( 'INSERT INTO tasks (project_id, worker_id, title, description, priority, ' 'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)', @@ -368,6 +420,9 @@ def task_detail(tid): sets.append(f'{f}=?') args.append(d[f]) if 'depends_on' in d: + ok, err = _validate_depends_on(t['project_id'], tid, d['depends_on']) + if not ok: + return jsonify({'ok': False, 'error': err}), 400 sets.append('depends_on=?') args.append(_json.dumps(d['depends_on'] or [])) if sets: diff --git a/static/app.js b/static/app.js index bcd2aa6..60c251b 100644 --- a/static/app.js +++ b/static/app.js @@ -390,7 +390,6 @@ function drawDag() { const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done'))); const body = $('#tab-body'); if (!body) return; - const NW = 140, NHW = 70; // 节点宽 / 半宽 const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中 const dagText = t => { const raw = `#${t.id} ${t.title}`; @@ -407,28 +406,43 @@ function drawDag() {
🗑️ 删除区0
+ ${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 mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; const act = idMap[e.from].status === 'done'; - return ``; + const sel = dagState.selectedEdge && dagState.selectedEdge.from === e.from && dagState.selectedEdge.to === e.to; + return ` + + + + + `; }).join('')} + ${tasks.map(t => { const p = dagState.pos[t.id]; const col = colors[t.status]; return ` - + + + ${esc(dagText(t))} ${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''} `; }).join('')} -
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。
`; +
💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。连线操作:从节点右侧⚪拖到目标节点=新建依赖;单击连线选中后可重连/删除;双击连线直接删除;连线中点 ✕ 一键删除。
`; dagApplyView(); bindDagEvents(); dagTrashCount(); @@ -437,6 +451,140 @@ function drawDag() { let dagBound = false; // window 级监听只绑定一次 let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问) const DAG_M = 3000; // svg 盒边距:节点平移/缩放后仍在盒内可命中 +const DAG_NW = 140, DAG_NHW = 70; // 节点宽 / 半宽 + +/* --- DAG 连线(依赖边)编辑:新建 / 重连 / 删除 --- */ +function dagCanConnect(from, to, mode, oldTo) { + if (from === to) return {ok:false, why:'不能连接到自己'}; + const t = projCtx.tasks.find(x => x.id === to); + if (!t) return {ok:false, why:'目标任务不存在'}; + if (mode !== 'repoint' && (t.depends_on || []).includes(from)) return {ok:false, why:'该连线已存在'}; + if (dagWouldCycle(from, to, mode === 'repoint' ? {from, to: oldTo} : null)) return {ok:false, why:'会形成依赖环'}; + return {ok:true}; +} + +function dagWouldCycle(from, to, remove) { + // 临时加入 from→to(重连时先移除旧边 remove),判断是否成环 + const adj = {}; + projCtx.tasks.forEach(t => { + adj[t.id] = new Set((t.depends_on || []).filter(d => !(remove && t.id === remove.to && d === remove.from))); + }); + (adj[to] = adj[to] || new Set()).add(from); + const seen = new Set(), stack = [...(adj[from] || [])]; + while (stack.length) { + const n = stack.pop(); + if (n === from) return true; + if (seen.has(n)) continue; + seen.add(n); + (adj[n] || []).forEach(x => { if (!seen.has(x)) stack.push(x); }); + } + return false; +} + +async function dagApplyEdge(from, to, mode, oldTo) { + const chk = dagCanConnect(from, to, mode, oldTo); + if (!chk.ok) { toast(chk.why, 'err'); return; } + try { + if (mode === 'repoint') { + const old = projCtx.tasks.find(x => x.id === oldTo); + await api(`/api/tasks/${oldTo}`, {method:'PUT', body:{depends_on: (old?.depends_on || []).filter(x => x !== from)}}); + const nt = projCtx.tasks.find(x => x.id === to); + await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}}); + toast(`🔗 连线已重连:#${from} → #${to}`, 'ok'); + } else { + const nt = projCtx.tasks.find(x => x.id === to); + await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: [...new Set([...(nt?.depends_on || []), from])]}}); + toast(`🔗 已新建连线:#${from} → #${to}`, 'ok'); + } + dagClearSel(); + refreshProj(); + } catch (e) { toast('连线操作失败:' + e.message, 'err'); } +} + +async function dagDeleteEdge(from, to) { + const t = projCtx.tasks.find(x => x.id === to); + if (!t) return; + if (!confirm(`删除依赖连线 #${from} → #${to} ?\n(#${to} 将不再等待 #${from} 完成)`)) return; + try { + await api(`/api/tasks/${to}`, {method:'PUT', body:{depends_on: (t.depends_on || []).filter(x => x !== from)}}); + toast('连线已删除', 'ok'); + dagCancelConnect(); + dagClearSel(); + refreshProj(); + } catch (e) { toast('删除失败:' + e.message, 'err'); } +} + +function dagSelectEdge(from, to) { + dagState.selectedEdge = {from, to}; + const svg = $('#dag-svg'); + if (svg) svg.querySelectorAll('.dag-edge-g').forEach(g => g.classList.toggle('sel', g.dataset.e === `${from}-${to}`)); + const bar = $('#dag-edge-actions'); + if (bar) { $('#dag-edge-info').innerHTML = `#${from}#${to}`; bar.style.display = 'flex'; } +} + +function dagClearSel() { + if (dagState?.connect) dagCancelConnect(); + if (dagState) dagState.selectedEdge = null; + const svg = $('#dag-svg'); + if (svg) svg.querySelectorAll('.dag-edge-g.sel').forEach(g => g.classList.remove('sel')); + const bar = $('#dag-edge-actions'); + if (bar) bar.style.display = 'none'; +} + +function dagRepointSel() { + const sel = dagState?.selectedEdge; + if (!sel) return; + dagState.connect = {from: sel.from, mode: 'repoint', oldTo: sel.to}; + const box = $('#dag-box'); if (box) box.classList.add('dag-connecting'); + const bar = $('#dag-edge-actions'); + if (bar) { $('#dag-edge-info').innerHTML = `#${sel.from}?(点击目标节点)`; bar.style.display = 'flex'; } + const a = dagState.pos[sel.from]; + if (a) dagSetGhost(a.x + DAG_NHW, a.y, a.x + DAG_NHW, a.y, true); + toast('点击目标节点完成重连,点空白 / Esc 取消', ''); +} + +function dagDelSel() { + const sel = dagState?.selectedEdge; + if (sel) dagDeleteEdge(sel.from, sel.to); +} + +function dagCancelConnect() { + if (!dagState) return; + dagState.connect = null; + const box = $('#dag-box'); if (box) box.classList.remove('dag-connecting'); + const g = $('#dag-ghost'); if (g) g.style.display = 'none'; + const svg = $('#dag-svg'); + if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad')); + const bar = $('#dag-edge-actions'); + if (bar) { + bar.style.display = dagState.selectedEdge ? 'flex' : 'none'; + if (dagState.selectedEdge) { + const {from, to} = dagState.selectedEdge; + $('#dag-edge-info').innerHTML = `#${from}#${to}`; + } + } +} + +function dagSetGhost(x1, y1, x2, y2, valid) { + const g = $('#dag-ghost'); if (!g) return; + const mx = (x1 + x2) / 2; + g.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`); + g.style.display = ''; + g.classList.toggle('bad', !valid); +} + +function dagUpdateEdgeEl(svg, from, to) { + const g = svg.querySelector(`.dag-edge-g[data-e="${from}-${to}"]`); + if (!g) return; + const a = dagState.pos[from], b = dagState.pos[to]; + if (!a || !b) return; + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const d = `M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}`; + g.querySelectorAll('path').forEach(p => p.setAttribute('d', d)); + const xc = g.querySelector('.dag-edge-x'); if (xc) { xc.setAttribute('cx', mx); xc.setAttribute('cy', my); } + const xt = g.querySelector('.dag-edge-xt'); if (xt) { xt.setAttribute('x', mx); xt.setAttribute('y', my + 3.5); } +} + function bindDagEvents() { const box = $('#dag-box'); if (!box) return; @@ -446,13 +594,29 @@ function bindDagEvents() { // box 级监听:每次 DOM 重建后重新绑定(box 是新元素) box.addEventListener('mousedown', e => { if (e.button !== 0) return; + const portOut = e.target.closest('.dag-port.out'); const nodeEl = e.target.closest('.dag-node'); + if (portOut) { + // 从节点右侧输出口拖出 → 新建连线 + const id = Number(portOut.dataset.id); + const p = s.pos[id]; + if (!p) return; + s.connect = {from: id, mode: 'create'}; + box.classList.add('dag-connecting'); + dagSetGhost(p.x + DAG_NHW, p.y, p.x + DAG_NHW, p.y, true); + e.preventDefault(); + return; + } if (nodeEl) { + if (e.target.closest('.dag-port.in')) { e.preventDefault(); return; } // 输入口只作落点,不拖节点 const id = Number(nodeEl.dataset.id); const p = s.pos[id]; dagDrag = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false}; nodeEl.style.cursor = 'grabbing'; const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing'; + } else if (e.target.closest('.dag-edge-g') || e.target.closest('.dag-edge-actions')) { + e.preventDefault(); // 点连线 / 操作条不触发平移 + return; } else { 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'; @@ -475,29 +639,82 @@ function bindDagEvents() { saveDagState(); }, {passive: false}); - // 双击空白复位视图 + // 双击:连线=删除,节点=忽略,空白=复位视图(控件条除外) box.addEventListener('dblclick', e => { + if (e.target.closest('.dag-edge-actions, .dag-zoom, .dag-trash')) return; + const edgeG = e.target.closest('.dag-edge-g'); + if (edgeG) { + const [f, t] = edgeG.dataset.e.split('-').map(Number); + dagDeleteEdge(f, t); + return; + } if (e.target.closest('.dag-node')) return; s.scale = 1; s.tx = 0; s.ty = 0; dagApplyView(); saveDagState(); }); - // 点击节点打开详情(拖动超过阈值则不触发) + // 点击:连线=选中/删除,节点=详情,空白=取消选中(控件条除外) box.addEventListener('click', e => { + if (e.target.closest('.dag-edge-actions, .dag-zoom, .dag-trash')) return; + const edgeG = e.target.closest('.dag-edge-g'); + if (edgeG) { + const [f, t] = edgeG.dataset.e.split('-').map(Number); + if (e.target.closest('.dag-edge-x')) { dagDeleteEdge(f, t); return; } + dagSelectEdge(f, t); + return; + } const nodeEl = e.target.closest('.dag-node'); - if (!nodeEl) return; - if (dagState.suppressClick) { dagState.suppressClick = false; return; } - openTaskDetail(Number(nodeEl.dataset.id)); + if (nodeEl) { + if (dagState.suppressClick) { dagState.suppressClick = false; return; } + openTaskDetail(Number(nodeEl.dataset.id)); + return; + } + dagClearSel(); }); // window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建) if (dagBound) return; dagBound = true; + + window.addEventListener('keydown', e => { + if (document.activeElement && /input|textarea|select/i.test(document.activeElement.tagName)) return; + if (e.key === 'Escape') { if (dagState?.connect) dagCancelConnect(); } + if ((e.key === 'Delete' || e.key === 'Backspace') && dagState?.selectedEdge && !dagState?.connect) { + dagDelSel(); + } + }); + window.addEventListener('mousemove', e => { - if (!dagDrag) return; const st = dagState; if (!st) return; + // 连线模式:幽灵线 + 目标节点合法性高亮 + if (st.connect) { + const c = st.connect; + const boxEl = $('#dag-box'); + if (!boxEl) return; + const rect = boxEl.getBoundingClientRect(); + const vx = (e.clientX - rect.left - st.tx) / st.scale; + const vy = (e.clientY - rect.top - st.ty) / st.scale; + const a = st.pos[c.from]; + const el = document.elementFromPoint(e.clientX, e.clientY); + const nd = el && el.closest ? el.closest('.dag-node') : null; + const target = nd ? Number(nd.dataset.id) : null; + const svg = svgEl(); + if (svg) svg.querySelectorAll('.dag-node.drop-ok,.dag-node.drop-bad').forEach(n => n.classList.remove('drop-ok', 'drop-bad')); + let valid = false; + if (target && target !== c.from) { + const chk = dagCanConnect(c.from, target, c.mode, c.oldTo); + valid = chk.ok; + if (svg) { + const tn = svg.querySelector(`.dag-node[data-id="${target}"]`); + if (tn) tn.classList.add(valid ? 'drop-ok' : 'drop-bad'); + } + } + if (a) dagSetGhost(a.x + DAG_NHW, a.y, vx, vy, valid); + return; + } + if (!dagDrag) 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; @@ -522,8 +739,12 @@ function bindDagEvents() { const nid = Number(g.dataset.id); const pp = st.pos[nid]; if (!pp) return; - g.querySelector('rect').setAttribute('x', pp.x - 70); + g.querySelector('rect').setAttribute('x', pp.x - DAG_NHW); g.querySelector('rect').setAttribute('y', pp.y - 24); + g.querySelectorAll('.dag-port').forEach(port => { + port.setAttribute('cx', pp.x + (port.classList.contains('out') ? DAG_NHW : -DAG_NHW)); + port.setAttribute('cy', pp.y); + }); 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); @@ -533,17 +754,10 @@ function bindDagEvents() { 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 = st.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}`); + if (st.pos[d]) dagUpdateEdgeEl(svgEl2, d, tid); }); }); }); @@ -551,6 +765,25 @@ function bindDagEvents() { }); window.addEventListener('mouseup', e => { + const st = dagState; + if (!st) return; + // 连线模式结束:落到合法节点则应用,否则取消 + if (st.connect) { + const c = st.connect; + const nd = e.target && e.target.closest ? e.target.closest('.dag-node') : null; + if (nd) { + const target = Number(nd.dataset.id); + if (c.mode === 'create') dagApplyEdge(c.from, target, 'create'); + else if (target !== c.from && target !== c.oldTo) dagApplyEdge(c.from, target, 'repoint', c.oldTo); + else toast('重连取消(目标未变化)', ''); + } else { + toast('已取消连线', ''); + } + dagCancelConnect(); + dagState.suppressClick = true; + setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400); + return; + } if (!dagDrag) return; const wasNode = dagDrag.type === 'node'; const trashEl = $('#dag-trash'); @@ -669,6 +902,7 @@ function renderDag() { tx: savedView?.tx || 0, ty: savedView?.ty || 0, drag: null, moved: false, + selectedEdge: null, connect: null, }; const body2 = $('#tab-body'); body2.innerHTML = ''; diff --git a/static/style.css b/static/style.css index be5961e..d7d64c1 100644 --- a/static/style.css +++ b/static/style.css @@ -138,8 +138,28 @@ tr:hover td{background:var(--panel2)} .dag-node rect{stroke-width:1.5;rx:10} .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{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow);pointer-events:none} .dag-edge.act{stroke:var(--accent)} +.dag-edge-g{cursor:pointer} +.dag-edge-hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer} +.dag-edge-g:hover .dag-edge{stroke:var(--accent)} +.dag-edge.sel{stroke:var(--accent);stroke-width:2.5} +.dag-edge-x{opacity:0;fill:var(--danger);stroke:#fff;stroke-width:1.5;pointer-events:none;cursor:pointer;transition:opacity .12s} +.dag-edge-xt{opacity:0;font-size:10px;fill:#fff;text-anchor:middle;pointer-events:none;transition:opacity .12s} +.dag-edge-g:hover .dag-edge-x,.dag-edge-g:hover .dag-edge-xt,.dag-edge-g.sel .dag-edge-x,.dag-edge-g.sel .dag-edge-xt{opacity:1} +.dag-edge-g:hover .dag-edge-x,.dag-edge-g.sel .dag-edge-x{pointer-events:all} +.dag-port{fill:var(--panel2);stroke:var(--accent);stroke-width:2;opacity:0;pointer-events:all;transition:opacity .15s} +.dag-node:hover .dag-port{opacity:1} +.dag-connecting .dag-port{opacity:1} +.dag-port.out{cursor:crosshair} +.dag-port.in{cursor:crosshair} +.dag-node.drop-ok rect{stroke:var(--accent2);stroke-width:2.5} +.dag-node.drop-bad rect{stroke:var(--danger);stroke-width:2.5} +.dag-ghost{stroke:var(--accent);stroke-width:2;stroke-dasharray:6 4;fill:none;marker-end:url(#arrow);pointer-events:none} +.dag-ghost.bad{stroke:var(--danger)} +.dag-edge-actions{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:rgba(23,30,46,.92);border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;z-index:7;backdrop-filter:blur(2px);box-shadow:0 4px 14px rgba(0,0,0,.35)} +.dag-edge-actions span{color:var(--muted);white-space:nowrap} +.dag-edge-actions b{color: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}