DAG画布:文字清晰 + 无边画布 + 删除区回收站
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)
This commit is contained in:
@@ -166,8 +166,8 @@ def projects():
|
|||||||
return jsonify({'ok': True, 'id': pid})
|
return jsonify({'ok': True, 'id': pid})
|
||||||
rows = db.q('SELECT * FROM projects ORDER BY id DESC')
|
rows = db.q('SELECT * FROM projects ORDER BY id DESC')
|
||||||
for r in rows:
|
for r in rows:
|
||||||
r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=?', (r['id'],))[0]['c']
|
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"',
|
r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
|
||||||
(r['id'],))[0]['c']
|
(r['id'],))[0]['c']
|
||||||
return jsonify({'ok': True, 'data': rows})
|
return jsonify({'ok': True, 'data': rows})
|
||||||
|
|
||||||
@@ -327,9 +327,9 @@ def tasks():
|
|||||||
return jsonify({'ok': True, 'id': tid})
|
return jsonify({'ok': True, 'id': tid})
|
||||||
pid = request.args.get('project_id')
|
pid = request.args.get('project_id')
|
||||||
if pid:
|
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:
|
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]})
|
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)
|
(t['worker_id'],), one=True)
|
||||||
return jsonify({'ok': True, 'data': t})
|
return jsonify({'ok': True, 'data': t})
|
||||||
if request.method == 'DELETE':
|
if request.method == 'DELETE':
|
||||||
|
# 软删除(进回收站);?hard=1 物理删除
|
||||||
|
if request.args.get('hard') == '1':
|
||||||
db.w('DELETE FROM task_logs WHERE task_id=?', (tid,))
|
db.w('DELETE FROM task_logs WHERE task_id=?', (tid,))
|
||||||
db.w('DELETE FROM cost_records WHERE task_id=?', (tid,))
|
db.w('DELETE FROM cost_records WHERE task_id=?', (tid,))
|
||||||
db.w('DELETE FROM tasks WHERE id=?', (tid,))
|
db.w('DELETE FROM tasks WHERE id=?', (tid,))
|
||||||
return jsonify({'ok': True})
|
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)
|
d = request.get_json(force=True)
|
||||||
# 只允许在非运行中修改基础字段
|
# 只允许在非运行中修改基础字段
|
||||||
if t['status'] == 'running':
|
if t['status'] == 'running':
|
||||||
@@ -372,12 +376,59 @@ def task_detail(tid):
|
|||||||
return jsonify({'ok': True})
|
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/<int:tid>/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/<int:tid>/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/<int:tid>/run', methods=['POST'])
|
@app.route('/api/tasks/<int:tid>/run', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def task_run(tid):
|
def task_run(tid):
|
||||||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||||||
if not t:
|
if not t:
|
||||||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||||||
|
if t['deleted']:
|
||||||
|
return jsonify({'ok': False, 'error': '任务已在回收站,请先恢复'}), 400
|
||||||
if t['status'] == 'running':
|
if t['status'] == 'running':
|
||||||
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
||||||
ok, blockers = engine.check_dependencies(t)
|
ok, blockers = engine.check_dependencies(t)
|
||||||
@@ -477,8 +528,8 @@ def stats():
|
|||||||
'by_status': {}, 'recent': [], 'daily_cost': []}
|
'by_status': {}, 'recent': [], 'daily_cost': []}
|
||||||
out['projects'] = db.q('SELECT COUNT(*) c FROM projects')[0]['c']
|
out['projects'] = db.q('SELECT COUNT(*) c FROM projects')[0]['c']
|
||||||
out['workers'] = db.q('SELECT COUNT(*) c FROM workers')[0]['c']
|
out['workers'] = db.q('SELECT COUNT(*) c FROM workers')[0]['c']
|
||||||
out['tasks'] = db.q('SELECT COUNT(*) c FROM tasks')[0]['c']
|
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 GROUP BY status'):
|
for r in db.q('SELECT status, COUNT(*) c FROM tasks WHERE deleted=0 GROUP BY status'):
|
||||||
out['by_status'][r['status']] = r['c']
|
out['by_status'][r['status']] = r['c']
|
||||||
c = db.q('SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens '
|
c = db.q('SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens '
|
||||||
'FROM cost_records')[0]
|
'FROM cost_records')[0]
|
||||||
@@ -486,14 +537,14 @@ def stats():
|
|||||||
|
|
||||||
# 一次通过率:done 且 rejection_count=0
|
# 一次通过率:done 且 rejection_count=0
|
||||||
done = out['by_status'].get('done', 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['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(
|
out['recent'] = db.q(
|
||||||
'SELECT t.id, t.title, t.status, t.project_id, p.name AS project_name, t.updated_at '
|
'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 '
|
'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 天成本
|
# 近 7 天成本
|
||||||
import datetime
|
import datetime
|
||||||
@@ -522,7 +573,7 @@ def logs():
|
|||||||
@require_auth
|
@require_auth
|
||||||
def workflow_run(pid):
|
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 = [], []
|
started, blocked = [], []
|
||||||
for t in rows:
|
for t in rows:
|
||||||
ok, blockers = engine.check_dependencies(t)
|
ok, blockers = engine.check_dependencies(t)
|
||||||
@@ -538,7 +589,7 @@ def workflow_run(pid):
|
|||||||
@require_auth
|
@require_auth
|
||||||
def project_dag(pid):
|
def project_dag(pid):
|
||||||
"""DAG 图数据:节点 + 边"""
|
"""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 = [], [], {}
|
nodes, edges, id_map = [], [], {}
|
||||||
for t in rows:
|
for t in rows:
|
||||||
node = db.serialize_task(t)
|
node = db.serialize_task(t)
|
||||||
|
|||||||
@@ -282,6 +282,10 @@ def _migrate():
|
|||||||
if 'depends_on' not in cols:
|
if 'depends_on' not in cols:
|
||||||
conn.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
|
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)')
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ def _build_messages(task, worker):
|
|||||||
|
|
||||||
def _trigger_downstream(task):
|
def _trigger_downstream(task):
|
||||||
"""DAG:任务完成后自动触发所有就绪的下游任务"""
|
"""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 = []
|
triggered = []
|
||||||
for t in rows:
|
for t in rows:
|
||||||
deps = _deps(t)
|
deps = _deps(t)
|
||||||
|
|||||||
+175
-79
@@ -360,13 +360,14 @@ function drawDag() {
|
|||||||
};
|
};
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="toolbar" style="margin-bottom:10px">
|
<div class="toolbar" style="margin-bottom:10px">
|
||||||
<span class="pager-tip">共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位</span>
|
<span class="pager-tip">共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位 / 拖节点到左下角🗑️删除</span>
|
||||||
<button class="btn success" ${ready.length ? '' : 'disabled'} onclick="runWorkflow()">▶ 执行整个工作流(${ready.length})</button>
|
<button class="btn success" ${ready.length ? '' : 'disabled'} onclick="runWorkflow()">▶ 执行整个工作流(${ready.length})</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dag-box" id="dag-box">
|
<div class="dag-box" id="dag-box">
|
||||||
<svg id="dag-svg" width="100%" height="${Math.max(480, height)}" viewBox="0 0 ${width} ${height}" style="transform-origin:0 0">
|
<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">
|
||||||
<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="-2000" y="-2000" width="${width + 4000}" height="${height + 4000}" fill="transparent"/>
|
<rect id="dag-bg" x="-4000" y="-4000" width="${width + 8000}" height="${height + 8000}" 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 '';
|
||||||
@@ -388,97 +389,34 @@ function drawDag() {
|
|||||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
|
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
|
||||||
dagApplyView();
|
dagApplyView();
|
||||||
bindDagEvents();
|
bindDagEvents();
|
||||||
|
dagTrashCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dagBound = false; // window 级监听只绑定一次
|
||||||
|
let dagDrag = null; // 模块级拖拽状态(box 重绑后 window handler 仍可访问)
|
||||||
function bindDagEvents() {
|
function bindDagEvents() {
|
||||||
const box = $('#dag-box');
|
const box = $('#dag-box');
|
||||||
const svg = $('#dag-svg');
|
if (!box) return;
|
||||||
if (!box || !svg) return;
|
|
||||||
const s = dagState;
|
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 => {
|
box.addEventListener('mousedown', e => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const nodeEl = e.target.closest('.dag-node');
|
const nodeEl = e.target.closest('.dag-node');
|
||||||
if (nodeEl) {
|
if (nodeEl) {
|
||||||
const id = Number(nodeEl.dataset.id);
|
const id = Number(nodeEl.dataset.id);
|
||||||
const p = s.pos[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';
|
nodeEl.style.cursor = 'grabbing';
|
||||||
svg.style.cursor = 'grabbing';
|
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
|
||||||
} else {
|
} else {
|
||||||
down = {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};
|
||||||
svg.style.cursor = 'grabbing';
|
const sv = svgEl(); if (sv) sv.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
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 => {
|
box.addEventListener('wheel', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -486,7 +424,6 @@ function bindDagEvents() {
|
|||||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||||
const ns = Math.min(3, Math.max(0.25, s.scale * factor));
|
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;
|
const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale;
|
||||||
s.scale = ns;
|
s.scale = ns;
|
||||||
s.tx = mx - cx * ns;
|
s.tx = mx - cx * ns;
|
||||||
@@ -510,6 +447,165 @@ function bindDagEvents() {
|
|||||||
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
|
||||||
openTaskDetail(Number(nodeEl.dataset.id));
|
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(`
|
||||||
|
<h3>🗑️ 回收站 <span class="tag">${rows.length} 个已删除任务</span></h3>
|
||||||
|
<div class="tab-note">被删除的任务都在这里,可恢复或彻底删除(不可恢复)。</div>
|
||||||
|
<table><thead><tr><th>ID</th><th>标题</th><th>状态</th><th>删除时间</th><th>操作</th></tr></thead><tbody>
|
||||||
|
${rows.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#${t.id}</td>
|
||||||
|
<td>${esc(t.title)}</td>
|
||||||
|
<td>${STATUS[t.status]?.label || t.status}</td>
|
||||||
|
<td>${fmtTime(t.deleted_at)}</td>
|
||||||
|
<td><button class="btn mini success" onclick="dagRestoreTask(${t.id})">♻️ 恢复</button>
|
||||||
|
<button class="btn mini danger" onclick="dagHardDelete(${t.id})">彻底删除</button></td>
|
||||||
|
</tr>`).join('') || '<tr><td colspan="5" class="empty-td">回收站是空的</td></tr>'}
|
||||||
|
</tbody></table>
|
||||||
|
<button class="btn" data-close>关闭</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderDag() {
|
||||||
@@ -741,9 +837,9 @@ async function reopenTask(tid) {
|
|||||||
toast('任务已重新打开', 'ok'); closeModal(); refreshProj();
|
toast('任务已重新打开', 'ok'); closeModal(); refreshProj();
|
||||||
}
|
}
|
||||||
async function delTask(tid) {
|
async function delTask(tid) {
|
||||||
if (!confirm('确认删除该任务及其日志/成本记录?')) return;
|
if (!confirm('删除后任务将移入回收站(DAG 画布左下角🗑️可恢复),确定?')) return;
|
||||||
await api(`/api/tasks/${tid}`, {method:'DELETE'});
|
await api(`/api/tasks/${tid}`, {method:'DELETE'});
|
||||||
toast('已删除', 'ok'); closeModal(); refreshProj();
|
toast('已移入回收站', 'ok'); closeModal(); refreshProj();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- AI 规划 WBS ---------- */
|
/* ---------- AI 规划 WBS ---------- */
|
||||||
|
|||||||
+6
-3
@@ -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{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)}
|
.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{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{stroke:var(--border);stroke-width:1.5;rx:10;cursor:grab}
|
||||||
.dag-node:hover{stroke:var(--accent)}
|
.dag-node:hover{stroke:var(--accent)}
|
||||||
.dag-node rect{stroke-width:1.5;rx:10}
|
.dag-node rect{stroke-width:1.5;rx:10}
|
||||||
.dag-label{font-size:12px;fill:#ffffff;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:10px;fill:rgba(255,255,255,.78);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)}
|
||||||
.dag-edge.act{stroke:var(--accent)}
|
.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{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.unread{border-left-color:var(--warn)}
|
||||||
.alert-row.critical{border-left-color:var(--danger)}
|
.alert-row.critical{border-left-color:var(--danger)}
|
||||||
|
|||||||
Reference in New Issue
Block a user