Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ccca61c3 | ||
|
|
3cbe3a5996 | ||
|
|
dfdcd78f1a | ||
|
|
9c45a143b9 | ||
|
|
cf092da69a | ||
|
|
5deb447c8e | ||
|
|
f28d73f1bb |
@@ -92,6 +92,23 @@ def _extract_score(text):
|
||||
return 60.0, text
|
||||
|
||||
|
||||
def _chat_json(worker, messages, max_tokens=2000, temperature=None):
|
||||
"""调用 LLM 并解析 JSON;解析失败自动加大 max_tokens 重试(防止长文截断)。
|
||||
返回 (data, raw_text, usage);全部失败抛最后异常。"""
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
mt = max_tokens * (attempt + 1) # 2000 → 4000 → 6000
|
||||
text, usage = _chat_worker(worker, messages, temperature=temperature, max_tokens=mt)
|
||||
try:
|
||||
data = _extract_json(text)
|
||||
if isinstance(data, dict) and not data:
|
||||
raise ValueError('空 JSON')
|
||||
return data, text, usage
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
raise last_err or ValueError('JSON 解析失败')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主管模式
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -114,17 +131,17 @@ SUPERVISOR_SYNTH_PROMPT = (
|
||||
def run_supervisor(run_id, run, workers):
|
||||
_log(run_id, 'supervisor', None, 'plan',
|
||||
f'主管开始规划:{run["topic"][:200]}')
|
||||
# 1) 主管拆解
|
||||
plan_text, u1 = _chat_worker(
|
||||
workers[0],
|
||||
[{'role': 'system', 'content': '你只输出 JSON。'},
|
||||
{'role': 'user', 'content': SUPERVISOR_PLAN_PROMPT.format(
|
||||
topic=run['topic'], context=run['context'] or '无')}],
|
||||
temperature=0.3, max_tokens=2000)
|
||||
# 1) 主管拆解(JSON 解析失败自动加大 max_tokens 重试)
|
||||
try:
|
||||
subtasks = _extract_json(plan_text)
|
||||
if isinstance(subtasks, dict):
|
||||
subtasks = subtasks.get('subtasks') or subtasks.get('tasks') or []
|
||||
data, _, u1 = _chat_json(
|
||||
workers[0],
|
||||
[{'role': 'system', 'content': '你只输出 JSON。'},
|
||||
{'role': 'user', 'content': SUPERVISOR_PLAN_PROMPT.format(
|
||||
topic=run['topic'], context=run['context'] or '无')}],
|
||||
max_tokens=3000, temperature=0.3)
|
||||
subtasks = data.get('subtasks') or data.get('tasks') or []
|
||||
if isinstance(data, list):
|
||||
subtasks = data
|
||||
except Exception as e:
|
||||
_update_run(run_id, status='failed', error=f'主管规划解析失败: {e}')
|
||||
_log(run_id, 'supervisor', None, 'plan', f'❌ 规划解析失败: {e}')
|
||||
@@ -216,15 +233,19 @@ def run_review(run_id, run, workers):
|
||||
# 评审
|
||||
review_usage = None
|
||||
try:
|
||||
review_text, u2 = _chat_worker(
|
||||
review_data, review_text, u2 = _chat_json(
|
||||
reviewer_w,
|
||||
[{'role': 'system', 'content': '你只输出 JSON。'},
|
||||
{'role': 'user', 'content': REVIEWER_PROMPT.format(
|
||||
topic=run['topic'], output=final_output, rubric=rubric)}],
|
||||
temperature=0.2, max_tokens=1500)
|
||||
score, judgment = _extract_score(review_text)
|
||||
max_tokens=2000, temperature=0.2)
|
||||
review_usage = u2
|
||||
total_tokens += u2['total_tokens']; total_cost += u2['cost']
|
||||
if isinstance(review_data, dict):
|
||||
score = float(review_data.get('score', review_data.get('总分', 60)))
|
||||
judgment = review_data.get('judgment') or review_data.get('意见') or review_text
|
||||
else:
|
||||
score, judgment = _extract_score(review_text)
|
||||
except Exception as e:
|
||||
score, judgment = 0, f'评审调用失败: {e}'
|
||||
final_score = score
|
||||
@@ -325,14 +346,13 @@ def run_debate(run_id, run, workers):
|
||||
f"【{v['stance']}】(#{k}):{v['view'][:500]}"
|
||||
for k, v in views.items() if k != w['id'])
|
||||
try:
|
||||
text, u = _chat_worker(
|
||||
data, _, u = _chat_json(
|
||||
w,
|
||||
[{'role': 'system', 'content': '你只输出 JSON。'},
|
||||
{'role': 'user', 'content': DEBATE_REBUT_PROMPT.format(
|
||||
stance=mine['stance'], topic=run['topic'],
|
||||
my_view=mine['view'], others=others)}],
|
||||
temperature=0.7, max_tokens=800)
|
||||
data = _extract_json(text)
|
||||
max_tokens=1200, temperature=0.7)
|
||||
rebut = data.get('rebuttal') if isinstance(data, dict) else text
|
||||
mine['view'] = mine['view'] + '\n\n【质询回应】' + str(rebut)
|
||||
total_tokens += u['total_tokens']; total_cost += u['cost']
|
||||
@@ -346,12 +366,11 @@ def run_debate(run_id, run, workers):
|
||||
f"【{v['stance']}】{v['view']}" for v in views.values())
|
||||
_log(run_id, 'judge', judge_w['id'], 'verdict', '裁判综合裁决中…')
|
||||
try:
|
||||
verdict, u4 = _chat_worker(
|
||||
data, verdict, u4 = _chat_json(
|
||||
judge_w,
|
||||
[{'role': 'system', 'content': '你只输出 JSON。你是公正严明的首席裁判。'},
|
||||
{'role': 'user', 'content': JUDGE_PROMPT.format(topic=run['topic'], transcript=transcript)}],
|
||||
temperature=0.3, max_tokens=1500)
|
||||
data = _extract_json(verdict)
|
||||
max_tokens=2500, temperature=0.3)
|
||||
if isinstance(data, dict):
|
||||
consensus = data.get('consensus') or data.get('结论') or verdict
|
||||
summary = data.get('summary') or data.get('摘要') or ''
|
||||
|
||||
@@ -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)
|
||||
@@ -166,8 +214,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})
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -327,9 +379,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 +400,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':
|
||||
@@ -364,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:
|
||||
@@ -372,12 +431,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/<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'])
|
||||
@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 +583,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 +592,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 +628,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 +644,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -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)
|
||||
|
||||
+643
-49
@@ -287,14 +287,10 @@ async function moveTask(tid, status) {
|
||||
toast('状态已更新', 'ok'); refreshProj();
|
||||
}
|
||||
|
||||
/* --- DAG 工作流 --- */
|
||||
function renderDag() {
|
||||
const {tasks} = projCtx;
|
||||
const idMap = {};
|
||||
tasks.forEach(t => { idMap[t.id] = t; });
|
||||
const edges = [];
|
||||
tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from:d, to:t.id}); }));
|
||||
/* --- DAG 工作流(可交互画布:拖节点 / 滚轮缩放 / 拖画布平移) --- */
|
||||
let dagState = null; // {pid, pos:{id:{x,y}}, scale, tx, ty, drag:null, moved:false}
|
||||
|
||||
function dagLayout(tasks, idMap) {
|
||||
// 拓扑分层:最长路径
|
||||
const layer = {};
|
||||
const visit = id => {
|
||||
@@ -308,43 +304,623 @@ function renderDag() {
|
||||
tasks.forEach(t => visit(t.id));
|
||||
const layers = {};
|
||||
tasks.forEach(t => { (layers[layer[t.id]] = layers[layer[t.id]] || []).push(t.id); });
|
||||
|
||||
const W = 260, H = 110, PAD = 60;
|
||||
const width = Math.max(2, Object.keys(layers).length) * W + PAD;
|
||||
const height = Math.max(...tasks.map(t => layer[t.id] === undefined ? 0 : layer[t.id]), 0) * H + PAD + 60;
|
||||
const pos = {};
|
||||
Object.entries(layers).forEach(([l, ids]) => {
|
||||
const x = PAD + parseInt(l) * W;
|
||||
const step = Math.min(H - 60, Math.max(70, (height - 2*PAD) / (ids.length + 1)));
|
||||
const step = Math.min(H - 60, Math.max(70, (height - 2 * PAD) / (ids.length + 1)));
|
||||
ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; });
|
||||
});
|
||||
return {width, height, pos};
|
||||
}
|
||||
|
||||
function dagSize() {
|
||||
const {tasks} = projCtx;
|
||||
const idMap = {};
|
||||
tasks.forEach(t => { idMap[t.id] = t; });
|
||||
return dagLayout(tasks, idMap);
|
||||
}
|
||||
|
||||
function dagFitView() {
|
||||
// 画布适配视口:基于节点实际包围盒缩放并居中,让全部节点可见
|
||||
const s = dagState;
|
||||
const box = $('#dag-box');
|
||||
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 scale = Math.max(0.25, Math.min(1, (bw - 60) / cw, (bh - 60) / ch));
|
||||
s.scale = scale;
|
||||
// 节点包围盒中心(viewBox 坐标)对准视口中心
|
||||
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();
|
||||
saveDagState();
|
||||
}
|
||||
|
||||
function dagZoomTo(scale) {
|
||||
// 一键回到指定比例(100% = 原始视图)
|
||||
const s = dagState;
|
||||
if (!s) return;
|
||||
s.scale = scale;
|
||||
s.tx = 0;
|
||||
s.ty = 0;
|
||||
dagApplyView();
|
||||
saveDagState();
|
||||
}
|
||||
|
||||
function saveDagState() {
|
||||
if (!dagState) return;
|
||||
try {
|
||||
const pos = {};
|
||||
Object.entries(dagState.pos).forEach(([k, v]) => { pos[k] = {x: Math.round(v.x), y: Math.round(v.y)}; });
|
||||
localStorage.setItem('aw_dag_pos_' + dagState.pid, JSON.stringify(pos));
|
||||
localStorage.setItem('aw_dag_view_' + dagState.pid,
|
||||
JSON.stringify({scale: dagState.scale, tx: Math.round(dagState.tx), ty: Math.round(dagState.ty)}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function dagApplyView() {
|
||||
const s = dagState;
|
||||
const svg = $('#dag-svg');
|
||||
if (!svg) return;
|
||||
svg.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`;
|
||||
svg.style.transformOrigin = '0 0';
|
||||
const pct = $('#dag-zoom-pct');
|
||||
if (pct) pct.textContent = Math.round(s.scale * 100) + '%';
|
||||
}
|
||||
|
||||
function drawDag() {
|
||||
const {tasks} = projCtx;
|
||||
const idMap = {};
|
||||
tasks.forEach(t => { idMap[t.id] = t; });
|
||||
const edges = [];
|
||||
tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from: d, to: t.id}); }));
|
||||
const {width, height} = dagSize();
|
||||
const colors = {todo:'#2a3550', running:'#173b6b', review:'#4a3a10', done:'#0f3d2e', rejected:'#4a1c1c', failed:'#3d1c2e', cancelled:'#333'};
|
||||
const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done')));
|
||||
$('#tab-body').innerHTML = `
|
||||
const body = $('#tab-body');
|
||||
if (!body) return;
|
||||
const M = DAG_M; // svg 盒边距:节点平移/缩放后仍在盒内可命中
|
||||
const dagText = t => {
|
||||
const raw = `#${t.id} ${t.title}`;
|
||||
return raw.length > 13 ? raw.slice(0, 13) + '…' : raw;
|
||||
};
|
||||
body.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<div class="dag-box"><svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<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>
|
||||
${edges.map(e => {
|
||||
const a = pos[e.from], b = pos[e.to];
|
||||
if (!a || !b) return '';
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const act = idMap[e.from].status === 'done';
|
||||
return `<path class="dag-edge${act ? ' act' : ''}" d="M${a.x+55},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x-55},${b.y}"/>`;
|
||||
}).join('')}
|
||||
${tasks.map(t => {
|
||||
const p = pos[t.id];
|
||||
const col = colors[t.status];
|
||||
return `<g>
|
||||
<rect class="dag-node" x="${p.x-55}" y="${p.y-24}" width="110" height="48" fill="${col}" stroke="#3a4a6a" onclick="openTaskDetail(${t.id})"/>
|
||||
<text class="dag-label" x="${p.x}" y="${p.y-2}" fill="#fff">#${t.id} ${esc(t.title).slice(0,10)}</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>`;
|
||||
}).join('')}
|
||||
</svg></div>
|
||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
|
||||
<div class="dag-box" id="dag-box">
|
||||
<div class="dag-zoom" id="dag-zoom">🔍 <b id="dag-zoom-pct">100%</b>
|
||||
<button onclick="dagFitView()" title="缩放至适配整个画布">⤢ 适配</button>
|
||||
<button onclick="dagZoomTo(1)" title="一键回到 100% 原始视图">100%</button>
|
||||
</div>
|
||||
<div class="dag-trash" id="dag-trash" onclick="openDagTrash()" title="点击查看回收站">🗑️ 删除区<span class="cnt" id="dag-trash-cnt">0</span></div>
|
||||
<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>
|
||||
<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 => {
|
||||
const a = dagState.pos[e.from], b = dagState.pos[e.to];
|
||||
if (!a || !b) return '';
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
const act = idMap[e.from].status === 'done';
|
||||
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('')}
|
||||
<path id="dag-ghost" class="dag-ghost" style="display:none"/>
|
||||
${tasks.map(t => {
|
||||
const p = dagState.pos[t.id];
|
||||
const col = colors[t.status];
|
||||
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
|
||||
<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-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
|
||||
</g>`;
|
||||
}).join('')}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。连线操作:从节点<b>右侧⚪</b>拖到目标节点=新建依赖;<b>单击连线</b>选中后可重连/删除;<b>双击连线</b>直接删除;连线中点 ✕ 一键删除。</div>`;
|
||||
dagApplyView();
|
||||
bindDagEvents();
|
||||
dagTrashCount();
|
||||
}
|
||||
|
||||
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 = `<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() {
|
||||
const box = $('#dag-box');
|
||||
if (!box) return;
|
||||
const s = dagState;
|
||||
const svgEl = () => $('#dag-svg');
|
||||
|
||||
// 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';
|
||||
}
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// 滚轮缩放(以鼠标为锚点)
|
||||
box.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const rect = box.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = Math.min(3, Math.max(0.25, s.scale * factor));
|
||||
const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale;
|
||||
s.scale = ns;
|
||||
s.tx = mx - cx * ns;
|
||||
s.ty = my - cy * ns;
|
||||
dagApplyView();
|
||||
saveDagState();
|
||||
}, {passive: false});
|
||||
|
||||
// 双击:连线=删除,节点=忽略,空白=复位视图(控件条除外)
|
||||
box.addEventListener('dblclick', e => {
|
||||
if (e.target.closest('.dag-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) {
|
||||
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 => {
|
||||
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;
|
||||
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 = st.pos[nid];
|
||||
if (!pp) return;
|
||||
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);
|
||||
});
|
||||
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 => {
|
||||
projCtx.tasks.forEach(t => {
|
||||
if (t.id !== tid) return;
|
||||
(t.depends_on || []).forEach(d => {
|
||||
if (st.pos[d]) dagUpdateEdgeEl(svgEl2, d, tid);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
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() {
|
||||
const {tasks, pid} = projCtx;
|
||||
const idMap = {};
|
||||
tasks.forEach(t => { idMap[t.id] = t; });
|
||||
const {pos} = dagLayout(tasks, idMap);
|
||||
// 恢复已保存布局 / 视图
|
||||
let savedPos = null, savedView = null;
|
||||
try {
|
||||
savedPos = JSON.parse(localStorage.getItem('aw_dag_pos_' + pid) || 'null');
|
||||
savedView = JSON.parse(localStorage.getItem('aw_dag_view_' + pid) || 'null');
|
||||
} catch (e) {}
|
||||
if (savedPos) Object.entries(savedPos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
|
||||
dagState = {
|
||||
pid, pos,
|
||||
scale: savedView?.scale || 1,
|
||||
tx: savedView?.tx || 0,
|
||||
ty: savedView?.ty || 0,
|
||||
drag: null, moved: false,
|
||||
selectedEdge: null, connect: null,
|
||||
};
|
||||
const body2 = $('#tab-body');
|
||||
body2.innerHTML = '';
|
||||
drawDag();
|
||||
// 节点是否全部在视口内?任一越界则自动适配(防止节点被平移到视口外无法操作)
|
||||
const box = $('#dag-box');
|
||||
const bb = box ? box.getBoundingClientRect() : null;
|
||||
let allVisible = !bb;
|
||||
if (bb) {
|
||||
allVisible = tasks.every(t => {
|
||||
const p = dagState.pos[t.id];
|
||||
if (!p) return true;
|
||||
const vx = bb.left + dagState.tx + (DAG_M + p.x) * dagState.scale;
|
||||
const vy = bb.top + dagState.ty + (DAG_M + p.y) * dagState.scale;
|
||||
return vx < bb.right && vx > bb.left && vy < bb.bottom && vy > bb.top;
|
||||
});
|
||||
}
|
||||
if (!allVisible || !savedView) dagFitView();
|
||||
}
|
||||
|
||||
async function runWorkflow() {
|
||||
@@ -552,9 +1128,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 ---------- */
|
||||
@@ -940,6 +1516,20 @@ function renderAgents() {
|
||||
const {modes, workers, runs} = agentsCtx;
|
||||
const wOpts = workers.map(w =>
|
||||
`<option value="${w.id}">${esc(w.name)}(${esc(w.model)})</option>`).join('');
|
||||
// 各模式专属参数定义(m: 适用模式列表)
|
||||
const paramRows = [
|
||||
{id: 'ag-workers', m: 'all', html: `<label data-modes="all">参与 Agent(可多选)<select id="ag-workers" multiple size="3">${wOpts}</select></label>`},
|
||||
{id: 'ag-rounds', m: 'review', html: `<label data-modes="review">最大返工轮次<select id="ag-rounds"><option value="1">1 轮(快)</option><option value="2" selected>2 轮(推荐)</option><option value="3">3 轮(严苛)</option></select></label>`},
|
||||
{id: 'ag-pass', m: 'review', html: `<label data-modes="review">通过分数线<select id="ag-pass"><option value="60">60 分(宽松)</option><option value="70" selected>70 分(标准)</option><option value="80">80 分(严格)</option><option value="90">90 分(严苛)</option></select></label>`},
|
||||
{id: 'ag-debate-rounds', m: 'debate', html: `<label data-modes="debate">质询轮次<select id="ag-debate-rounds"><option value="1" selected>1 轮</option><option value="2">2 轮</option><option value="3">3 轮</option></select></label>`},
|
||||
{id: 'ag-stances', m: 'debate', html: `<label data-modes="debate">辩论立场(逗号分隔,按辩手顺序分配)<input id="ag-stances" placeholder="正方,反方,中立"></label>`},
|
||||
{id: 'ag-context', m: 'all', html: `<label data-modes="all" style="grid-column:span 2">背景上下文<textarea id="ag-context" rows="2" placeholder="选填:背景资料、约束条件、参考知识"></textarea></label>`},
|
||||
];
|
||||
const modeHints = {
|
||||
supervisor: '💡 第 1 个 Agent 担任主管(负责拆解与最终合成),其余 Agent 并行执行子任务。',
|
||||
review: '💡 第 1 个 Agent 为执行者,第 2 个为评审(只选 1 个时由同一 Agent 自评)。低于分数线自动返工,最多重试所选轮次。',
|
||||
debate: '💡 除最后 1 个 Agent 为裁判外,其余均为辩手;立场按顺序分配,裁判综合裁决输出共识。',
|
||||
};
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">多 Agent 协作<small>主管 / 评审 / 辩论 三种团队协作模式</small></h1>
|
||||
<div class="grid g3">${modes.map(m => `
|
||||
@@ -947,25 +1537,18 @@ function renderAgents() {
|
||||
<div style="font-size:26px">${m.icon}</div>
|
||||
<div class="sec-title" style="margin:6px 0 4px">${esc(m.name)}</div>
|
||||
<div style="font-size:12px;color:var(--muted);min-height:34px">${esc(m.desc)}</div>
|
||||
<div style="margin-top:8px"><span class="tag">${m.params.length} 项参数</span></div>
|
||||
<div style="margin-top:8px"><span class="tag">${m.id === 'supervisor' ? '拆解+委派+合成' : m.id === 'review' ? '产出+评审+返工' : '立论+质询+裁决'}</span></div>
|
||||
</div>`).join('')}</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">⚡ 发起协作运行</div>
|
||||
<div class="form-grid">
|
||||
<label>协作模式<select id="ag-mode">${modes.map(m => `<option value="${m.id}">${m.icon} ${esc(m.name)}</option>`).join('')}</select></label>
|
||||
<label>标题<input id="ag-title" placeholder="选填,默认自动生成"></label>
|
||||
<label>任务主题 / 议题<textarea id="ag-topic" rows="2" placeholder="例如:为新产品策划上市方案…"></textarea></label>
|
||||
<label>参与 Agent(可多选)<select id="ag-workers" multiple size="3">${wOpts}</select></label>
|
||||
<label>最大返工轮次(评审)<input id="ag-rounds" type="number" value="2"></label>
|
||||
<label>通过分数线(评审)<input id="ag-pass" type="number" value="70"></label>
|
||||
<label>质询轮次(辩论)<input id="ag-debate-rounds" type="number" value="1"></label>
|
||||
<label>辩论立场(逗号分隔)<input id="ag-stances" placeholder="正方,反方,裁判"></label>
|
||||
<label style="grid-column:span 2">背景上下文<textarea id="ag-context" rows="2" placeholder="选填:背景资料、约束条件、参考知识"></textarea></label>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<button class="btn primary" id="ag-run">🚀 发起运行</button>
|
||||
<span class="tab-note">💡 评审模式第 1 个 Agent 为执行者,第 2 个(若有)为评审;辩论模式最后一个为裁判。</span>
|
||||
<label style="grid-column:span 2">任务主题 / 议题<textarea id="ag-topic" rows="2" placeholder="例如:为新产品策划上市方案…"></textarea></label>
|
||||
${paramRows.map(r => r.html).join('')}
|
||||
</div>
|
||||
<div class="tab-note" id="ag-hint" style="margin-top:10px">${modeHints.supervisor}</div>
|
||||
<div style="margin-top:10px"><button class="btn primary" id="ag-run">🚀 发起运行</button></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">📋 运行记录 <button class="btn mini" style="float:right" onclick="location.hash='#/agents'">刷新</button></div>
|
||||
@@ -982,10 +1565,21 @@ function renderAgents() {
|
||||
</tr>`).join('') || '<tr><td colspan="8" class="empty-td">暂无运行记录</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
$('#ag-mode').addEventListener('change', e => {
|
||||
const m = modes.find(x => x.id === e.target.value);
|
||||
toast('已选择:' + m.name, '');
|
||||
});
|
||||
// 模式切换:动态显示/隐藏专属参数
|
||||
const applyMode = mode => {
|
||||
$$('.form-grid [data-modes]').forEach(el => {
|
||||
const ms = el.dataset.modes.split(',');
|
||||
el.style.display = (ms.includes('all') || ms.includes(mode)) ? '' : 'none';
|
||||
});
|
||||
$('#ag-hint').textContent = modeHints[mode] || '';
|
||||
$$('.mode-card').forEach(c => c.style.outline = c.dataset.mode === mode ? '2px solid var(--accent)' : '');
|
||||
};
|
||||
$('#ag-mode').addEventListener('change', e => applyMode(e.target.value));
|
||||
$$('.mode-card').forEach(c => c.addEventListener('click', () => {
|
||||
$('#ag-mode').value = c.dataset.mode;
|
||||
applyMode(c.dataset.mode);
|
||||
}));
|
||||
applyMode($('#ag-mode').value);
|
||||
$('#ag-run').addEventListener('click', async () => {
|
||||
const mode = $('#ag-mode').value;
|
||||
const topic = $('#ag-topic').value.trim();
|
||||
|
||||
+34
-5
@@ -131,13 +131,42 @@ tr:hover td{background:var(--panel2)}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
||||
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
|
||||
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;overflow:auto}
|
||||
.dag-node{fill:var(--panel2);stroke:var(--border);stroke-width:1.5;rx:10;cursor:pointer}
|
||||
.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;overflow:visible}
|
||||
.dag-node{stroke:var(--border);stroke-width:1.5;rx:10;cursor:grab}
|
||||
.dag-node:hover{stroke:var(--accent)}
|
||||
.dag-label{font-size:12px;fill:var(--text);text-anchor:middle}
|
||||
.dag-sub{font-size:10px;fill:var(--muted);text-anchor:middle}
|
||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)}
|
||||
.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);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}
|
||||
.dag-zoom{position:absolute;right:14px;top:14px;background:rgba(23,30,46,.88);border:1px solid var(--border);border-radius:8px;padding:4px 10px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;z-index:6}
|
||||
.dag-zoom b{color:var(--text);font-weight:600;min-width:42px;text-align:center}
|
||||
.dag-zoom button{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:2px 8px;font-size:11px;cursor:pointer}
|
||||
.dag-zoom button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.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)}
|
||||
|
||||
Reference in New Issue
Block a user