Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ccca61c3 | ||
|
|
3cbe3a5996 |
@@ -35,6 +35,54 @@ def auth_enabled():
|
|||||||
return bool(config.AUTH_PASSWORD)
|
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'])
|
@app.route('/api/login', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
@@ -315,6 +363,10 @@ def providers():
|
|||||||
def tasks():
|
def tasks():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
d = request.get_json(force=True)
|
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(
|
tid = db.w(
|
||||||
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
||||||
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||||
@@ -368,6 +420,9 @@ def task_detail(tid):
|
|||||||
sets.append(f'{f}=?')
|
sets.append(f'{f}=?')
|
||||||
args.append(d[f])
|
args.append(d[f])
|
||||||
if 'depends_on' in d:
|
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=?')
|
sets.append('depends_on=?')
|
||||||
args.append(_json.dumps(d['depends_on'] or []))
|
args.append(_json.dumps(d['depends_on'] or []))
|
||||||
if sets:
|
if sets:
|
||||||
|
|||||||
+281
-36
@@ -324,16 +324,25 @@ function dagSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dagFitView() {
|
function dagFitView() {
|
||||||
// 画布适配视口:缩放并居中,让全部节点可见
|
// 画布适配视口:基于节点实际包围盒缩放并居中,让全部节点可见
|
||||||
const s = dagState;
|
const s = dagState;
|
||||||
const box = $('#dag-box');
|
const box = $('#dag-box');
|
||||||
const {width, height} = dagSize();
|
|
||||||
if (!box || !s) return;
|
if (!box || !s) return;
|
||||||
|
const poss = Object.values(s.pos || {});
|
||||||
|
if (!poss.length) return;
|
||||||
|
const minX = Math.min(...poss.map(p => p.x)) - 120;
|
||||||
|
const maxX = Math.max(...poss.map(p => p.x)) + 120;
|
||||||
|
const minY = Math.min(...poss.map(p => p.y)) - 120;
|
||||||
|
const maxY = Math.max(...poss.map(p => p.y)) + 120;
|
||||||
|
const cw = Math.max(200, maxX - minX), ch = Math.max(200, maxY - minY);
|
||||||
const bw = box.clientWidth, bh = box.clientHeight;
|
const bw = box.clientWidth, bh = box.clientHeight;
|
||||||
const scale = Math.max(0.25, Math.min(1, (bw - 60) / width, (bh - 60) / height));
|
const scale = Math.max(0.25, Math.min(1, (bw - 60) / cw, (bh - 60) / ch));
|
||||||
s.scale = scale;
|
s.scale = scale;
|
||||||
s.tx = (bw - width * scale) / 2;
|
// 节点包围盒中心(viewBox 坐标)对准视口中心
|
||||||
s.ty = (bh - height * scale) / 2;
|
const cx = DAG_M + (minX + maxX) / 2;
|
||||||
|
const cy = DAG_M + (minY + maxY) / 2;
|
||||||
|
s.tx = bw / 2 - cx * scale;
|
||||||
|
s.ty = bh / 2 - cy * scale;
|
||||||
dagApplyView();
|
dagApplyView();
|
||||||
saveDagState();
|
saveDagState();
|
||||||
}
|
}
|
||||||
@@ -381,7 +390,7 @@ 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 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');
|
const body = $('#tab-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const NW = 140, NHW = 70; // 节点宽 / 半宽
|
const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中
|
||||||
const dagText = t => {
|
const dagText = t => {
|
||||||
const raw = `#${t.id} ${t.title}`;
|
const raw = `#${t.id} ${t.title}`;
|
||||||
return raw.length > 13 ? raw.slice(0, 13) + '…' : raw;
|
return raw.length > 13 ? raw.slice(0, 13) + '…' : raw;
|
||||||
@@ -397,28 +406,43 @@ function drawDag() {
|
|||||||
<button onclick="dagZoomTo(1)" title="一键回到 100% 原始视图">100%</button>
|
<button onclick="dagZoomTo(1)" title="一键回到 100% 原始视图">100%</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dag-trash" id="dag-trash" onclick="openDagTrash()" title="点击查看回收站">🗑️ 删除区<span class="cnt" id="dag-trash-cnt">0</span></div>
|
<div class="dag-trash" id="dag-trash" onclick="openDagTrash()" title="点击查看回收站">🗑️ 删除区<span class="cnt" id="dag-trash-cnt">0</span></div>
|
||||||
<svg id="dag-svg" width="${width}" height="${Math.max(480, height)}" viewBox="0 0 ${width} ${height}" style="transform-origin:0 0">
|
<div class="dag-edge-actions" id="dag-edge-actions" style="display:none">
|
||||||
|
<span id="dag-edge-info"></span>
|
||||||
|
<button class="btn sm" onclick="dagRepointSel()" title="点击后选择新的目标节点">🔗 重连</button>
|
||||||
|
<button class="btn sm danger" onclick="dagDelSel()">🗑️ 删除</button>
|
||||||
|
<button class="btn sm" onclick="dagClearSel()" title="取消选中">✖ 取消</button>
|
||||||
|
</div>
|
||||||
|
<svg id="dag-svg" width="${width + M * 2}" height="${Math.max(480, height) + M * 2}" viewBox="${-M} ${-M} ${width + M * 2} ${height + M * 2}" style="transform-origin:0 0">
|
||||||
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
|
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
|
||||||
<rect id="dag-bg" x="-4000" y="-4000" width="${width + 8000}" height="${height + 8000}" fill="var(--panel)"/>
|
<rect id="dag-bg" x="${-M - 1000}" y="${-M - 1000}" width="${width + 2 * M + 2000}" height="${height + 2 * M + 2000}" fill="var(--panel)"/>
|
||||||
${edges.map(e => {
|
${edges.map(e => {
|
||||||
const a = dagState.pos[e.from], b = dagState.pos[e.to];
|
const a = dagState.pos[e.from], b = dagState.pos[e.to];
|
||||||
if (!a || !b) return '';
|
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';
|
const act = idMap[e.from].status === 'done';
|
||||||
return `<path data-e="${e.from}-${e.to}" class="dag-edge${act ? ' act' : ''}" d="M${a.x + NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - NHW},${b.y}"/>`;
|
const sel = dagState.selectedEdge && dagState.selectedEdge.from === e.from && dagState.selectedEdge.to === e.to;
|
||||||
|
return `<g class="dag-edge-g${sel ? ' sel' : ''}" data-e="${e.from}-${e.to}">
|
||||||
|
<path class="dag-edge-hit" d="M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}"/>
|
||||||
|
<path class="dag-edge${act ? ' act' : ''}" d="M${a.x + DAG_NHW},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - DAG_NHW},${b.y}"/>
|
||||||
|
<circle class="dag-edge-x" cx="${mx}" cy="${my}" r="9"/>
|
||||||
|
<text class="dag-edge-xt" x="${mx}" y="${my + 3.5}">✕</text>
|
||||||
|
</g>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
<path id="dag-ghost" class="dag-ghost" style="display:none"/>
|
||||||
${tasks.map(t => {
|
${tasks.map(t => {
|
||||||
const p = dagState.pos[t.id];
|
const p = dagState.pos[t.id];
|
||||||
const col = colors[t.status];
|
const col = colors[t.status];
|
||||||
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
|
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
|
||||||
<rect x="${p.x - NHW}" y="${p.y - 24}" width="${NW}" height="48" fill="${col}" stroke="#5a6b8f"/>
|
<rect x="${p.x - DAG_NHW}" y="${p.y - 24}" width="${DAG_NW}" height="48" fill="${col}" stroke="#5a6b8f"/>
|
||||||
|
<circle class="dag-port in" data-id="${t.id}" cx="${p.x - DAG_NHW}" cy="${p.y}" r="6"/>
|
||||||
|
<circle class="dag-port out" data-id="${t.id}" cx="${p.x + DAG_NHW}" cy="${p.y}" r="6"/>
|
||||||
<text class="dag-label" x="${p.x}" y="${p.y - 2}">${esc(dagText(t))}</text>
|
<text class="dag-label" x="${p.x}" y="${p.y - 2}">${esc(dagText(t))}</text>
|
||||||
<text class="dag-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
|
<text class="dag-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
|
||||||
</g>`;
|
</g>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
|
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。连线操作:从节点<b>右侧⚪</b>拖到目标节点=新建依赖;<b>单击连线</b>选中后可重连/删除;<b>双击连线</b>直接删除;连线中点 ✕ 一键删除。</div>`;
|
||||||
dagApplyView();
|
dagApplyView();
|
||||||
bindDagEvents();
|
bindDagEvents();
|
||||||
dagTrashCount();
|
dagTrashCount();
|
||||||
@@ -426,6 +450,141 @@ function drawDag() {
|
|||||||
|
|
||||||
let dagBound = false; // window 级监听只绑定一次
|
let dagBound = false; // window 级监听只绑定一次
|
||||||
let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问)
|
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 = `<b>#${from}</b> → <b>#${to}</b>`; 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 = `<b>#${sel.from}</b> → <b style="color:var(--warn)">?(点击目标节点)</b>`; 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 = `<b>#${from}</b> → <b>#${to}</b>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function bindDagEvents() {
|
||||||
const box = $('#dag-box');
|
const box = $('#dag-box');
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
@@ -435,13 +594,29 @@ function bindDagEvents() {
|
|||||||
// box 级监听:每次 DOM 重建后重新绑定(box 是新元素)
|
// box 级监听:每次 DOM 重建后重新绑定(box 是新元素)
|
||||||
box.addEventListener('mousedown', e => {
|
box.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
const portOut = e.target.closest('.dag-port.out');
|
||||||
const nodeEl = e.target.closest('.dag-node');
|
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 (nodeEl) {
|
||||||
|
if (e.target.closest('.dag-port.in')) { e.preventDefault(); return; } // 输入口只作落点,不拖节点
|
||||||
const id = Number(nodeEl.dataset.id);
|
const id = Number(nodeEl.dataset.id);
|
||||||
const p = s.pos[id];
|
const p = s.pos[id];
|
||||||
dagDrag = {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';
|
nodeEl.style.cursor = 'grabbing';
|
||||||
const sv = svgEl(); if (sv) sv.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 {
|
} else {
|
||||||
dagDrag = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
|
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';
|
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
|
||||||
@@ -464,29 +639,82 @@ function bindDagEvents() {
|
|||||||
saveDagState();
|
saveDagState();
|
||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
// 双击空白复位视图
|
// 双击:连线=删除,节点=忽略,空白=复位视图(控件条除外)
|
||||||
box.addEventListener('dblclick', e => {
|
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;
|
if (e.target.closest('.dag-node')) return;
|
||||||
s.scale = 1; s.tx = 0; s.ty = 0;
|
s.scale = 1; s.tx = 0; s.ty = 0;
|
||||||
dagApplyView();
|
dagApplyView();
|
||||||
saveDagState();
|
saveDagState();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 点击节点打开详情(拖动超过阈值则不触发)
|
// 点击:连线=选中/删除,节点=详情,空白=取消选中(控件条除外)
|
||||||
box.addEventListener('click', e => {
|
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');
|
const nodeEl = e.target.closest('.dag-node');
|
||||||
if (!nodeEl) return;
|
if (nodeEl) {
|
||||||
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
||||||
openTaskDetail(Number(nodeEl.dataset.id));
|
openTaskDetail(Number(nodeEl.dataset.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dagClearSel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建)
|
// window 级监听:只绑定一次(handler 内现查 DOM,兼容画布重建)
|
||||||
if (dagBound) return;
|
if (dagBound) return;
|
||||||
dagBound = true;
|
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 => {
|
window.addEventListener('mousemove', e => {
|
||||||
if (!dagDrag) return;
|
|
||||||
const st = dagState;
|
const st = dagState;
|
||||||
if (!st) return;
|
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;
|
const dx = e.clientX - dagDrag.sx, dy = e.clientY - dagDrag.sy;
|
||||||
if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
|
if (!dagDrag.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
|
||||||
dagDrag.moved = true;
|
dagDrag.moved = true;
|
||||||
@@ -509,10 +737,14 @@ function bindDagEvents() {
|
|||||||
if (!svgEl2) return;
|
if (!svgEl2) return;
|
||||||
svgEl2.querySelectorAll('.dag-node').forEach(g => {
|
svgEl2.querySelectorAll('.dag-node').forEach(g => {
|
||||||
const nid = Number(g.dataset.id);
|
const nid = Number(g.dataset.id);
|
||||||
const pp = s.pos[nid];
|
const pp = st.pos[nid];
|
||||||
if (!pp) return;
|
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.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');
|
const ts = g.querySelectorAll('text');
|
||||||
ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2);
|
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);
|
ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15);
|
||||||
@@ -522,17 +754,10 @@ function bindDagEvents() {
|
|||||||
if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); }
|
if (d === dagDrag.id || t.id === dagDrag.id) { touched.add(d); touched.add(t.id); }
|
||||||
}));
|
}));
|
||||||
touched.forEach(tid => {
|
touched.forEach(tid => {
|
||||||
const a = st.pos[tid];
|
|
||||||
if (!a) return;
|
|
||||||
projCtx.tasks.forEach(t => {
|
projCtx.tasks.forEach(t => {
|
||||||
if (t.id !== tid) return;
|
if (t.id !== tid) return;
|
||||||
(t.depends_on || []).forEach(d => {
|
(t.depends_on || []).forEach(d => {
|
||||||
const b = s.pos[d];
|
if (st.pos[d]) dagUpdateEdgeEl(svgEl2, d, tid);
|
||||||
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}`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -540,6 +765,25 @@ function bindDagEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('mouseup', e => {
|
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;
|
if (!dagDrag) return;
|
||||||
const wasNode = dagDrag.type === 'node';
|
const wasNode = dagDrag.type === 'node';
|
||||||
const trashEl = $('#dag-trash');
|
const trashEl = $('#dag-trash');
|
||||||
@@ -658,24 +902,25 @@ function renderDag() {
|
|||||||
tx: savedView?.tx || 0,
|
tx: savedView?.tx || 0,
|
||||||
ty: savedView?.ty || 0,
|
ty: savedView?.ty || 0,
|
||||||
drag: null, moved: false,
|
drag: null, moved: false,
|
||||||
|
selectedEdge: null, connect: null,
|
||||||
};
|
};
|
||||||
const body2 = $('#tab-body');
|
const body2 = $('#tab-body');
|
||||||
body2.innerHTML = '';
|
body2.innerHTML = '';
|
||||||
drawDag();
|
drawDag();
|
||||||
// 节点是否在视口内?不在则自动适配(防止节点被平移到视口外无法操作)
|
// 节点是否全部在视口内?任一越界则自动适配(防止节点被平移到视口外无法操作)
|
||||||
const box = $('#dag-box');
|
const box = $('#dag-box');
|
||||||
const bb = box ? box.getBoundingClientRect() : null;
|
const bb = box ? box.getBoundingClientRect() : null;
|
||||||
let anyVisible = false;
|
let allVisible = !bb;
|
||||||
if (bb) {
|
if (bb) {
|
||||||
tasks.forEach(t => {
|
allVisible = tasks.every(t => {
|
||||||
const p = dagState.pos[t.id];
|
const p = dagState.pos[t.id];
|
||||||
if (!p) return;
|
if (!p) return true;
|
||||||
const vx = bb.left + dagState.tx + p.x * dagState.scale;
|
const vx = bb.left + dagState.tx + (DAG_M + p.x) * dagState.scale;
|
||||||
const vy = bb.top + dagState.ty + p.y * dagState.scale;
|
const vy = bb.top + dagState.ty + (DAG_M + p.y) * dagState.scale;
|
||||||
if (vx < bb.right && vx > bb.left && vy < bb.bottom && vy > bb.top) anyVisible = true;
|
return vx < bb.right && vx > bb.left && vy < bb.bottom && vy > bb.top;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!anyVisible || !savedView) dagFitView();
|
if (!allVisible || !savedView) dagFitView();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runWorkflow() {
|
async function runWorkflow() {
|
||||||
|
|||||||
+21
-1
@@ -138,8 +138,28 @@ tr:hover td{background:var(--panel2)}
|
|||||||
.dag-node rect{stroke-width:1.5;rx:10}
|
.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-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-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.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{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: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}
|
.dag-trash .cnt{background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:11px}
|
||||||
|
|||||||
Reference in New Issue
Block a user