V1: DAG多任务编排 + AI规划WBS + RAG知识库 + 预算告警中心 + 开放API + 飞书/企微/邮件通知
This commit is contained in:
+469
-60
@@ -1,4 +1,4 @@
|
||||
/* AI Worker 项目管理平台 - 前端 SPA */
|
||||
/* AI Worker 项目管理平台 - 前端 SPA(V1) */
|
||||
'use strict';
|
||||
|
||||
const $ = (sel, el = document) => el.querySelector(sel);
|
||||
@@ -15,6 +15,7 @@ const STATUS = {
|
||||
};
|
||||
const PRI = {high:'高', medium:'中', low:'低'};
|
||||
const KANBAN_ORDER = ['todo','running','review','done','rejected','failed'];
|
||||
const ALERT_LEVEL = {info:'ℹ️', warn:'⚠️', critical:'🚨'};
|
||||
|
||||
/* ---------- API ---------- */
|
||||
async function api(path, opts = {}) {
|
||||
@@ -62,18 +63,32 @@ function openModal(html) {
|
||||
function closeModal() { $('#modal-mask').style.display = 'none'; $('#modal-box').innerHTML = ''; }
|
||||
$('#modal-mask').addEventListener('click', e => { if (e.target.id === 'modal-mask') closeModal(); });
|
||||
|
||||
/* ---------- 告警角标 ---------- */
|
||||
async function refreshAlertBadge() {
|
||||
try {
|
||||
const r = await api('/api/alerts/unread_count');
|
||||
const b = $('#alert-badge');
|
||||
if (r.count > 0) { b.textContent = r.count; b.style.display = 'inline'; }
|
||||
else b.style.display = 'none';
|
||||
} catch (e) {}
|
||||
}
|
||||
setInterval(refreshAlertBadge, 30000);
|
||||
|
||||
/* ---------- Router ---------- */
|
||||
const routes = {
|
||||
'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject,
|
||||
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs
|
||||
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs,
|
||||
'alerts': pageAlerts, 'api': pageApiTokens, 'settings': pageSettings
|
||||
};
|
||||
function router() {
|
||||
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
|
||||
const [name, ...rest] = hash.split('/');
|
||||
const parts = hash.split('/');
|
||||
const name = parts[0];
|
||||
const fn = routes[name] || pageDashboard;
|
||||
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (name === 'project' ? 'projects' : name)));
|
||||
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts'};
|
||||
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name)));
|
||||
$('#main').innerHTML = '<div class="empty"><span class="spin"></span>加载中…</div>';
|
||||
fn(rest).catch(e => { $('#main').innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
||||
fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
||||
}
|
||||
window.addEventListener('hashchange', router);
|
||||
|
||||
@@ -172,58 +187,85 @@ function openProjectModal(p = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 项目详情(看板) ---------- */
|
||||
let kanbanData = null;
|
||||
async function pageProject([pid]) {
|
||||
/* ---------- 项目详情(看板 / DAG / 知识库) ---------- */
|
||||
let projCtx = null; // {pid, p, tasks, workers, tab}
|
||||
|
||||
async function pageProject([pid, tab]) {
|
||||
const p = (await api(`/api/projects/${pid}`)).data;
|
||||
const tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
|
||||
const workers = (await api('/api/workers')).data;
|
||||
kanbanData = {pid, tasks, workers};
|
||||
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban'};
|
||||
renderProjectShell();
|
||||
if (projCtx.tab === 'dag') renderDag();
|
||||
else if (projCtx.tab === 'kb') renderKb();
|
||||
else renderKanban();
|
||||
}
|
||||
|
||||
function renderProjectShell() {
|
||||
const {p, pid, tab} = projCtx;
|
||||
$('#main').innerHTML = `
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="#/projects">← 返回</a>
|
||||
<h1 class="page-title" style="margin:0;flex:1">${esc(p.name)}<small>${esc(p.objective || '')}</small></h1>
|
||||
<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '"')})">编辑项目</button>
|
||||
<button class="btn primary" onclick="openTaskModal(${pid})">+ 新建任务</button>
|
||||
<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>
|
||||
<button class="btn primary" onclick="openTaskModal()">+ 新建任务</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto"><div class="kanban" id="kanban">${KANBAN_ORDER.map(s => `
|
||||
<div class="kb-col" data-status="${s}">
|
||||
<div class="kb-head"><span><span class="badge ${STATUS[s].cls}">${STATUS[s].label}</span></span>
|
||||
<span class="cnt" id="cnt-${s}">0</span></div>
|
||||
<div class="kb-body" data-drop="${s}"></div>
|
||||
</div>`).join('')}</div></div>`;
|
||||
renderKanban();
|
||||
$$('.kb-body').forEach(bd => {
|
||||
bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); });
|
||||
bd.addEventListener('dragleave', () => bd.classList.remove('drag-over'));
|
||||
bd.addEventListener('drop', e => {
|
||||
e.preventDefault(); bd.classList.remove('drag-over');
|
||||
const tid = e.dataTransfer.getData('text/plain');
|
||||
moveTask(parseInt(tid), bd.dataset.drop);
|
||||
});
|
||||
});
|
||||
<div class="tabs">
|
||||
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
|
||||
<a href="#/project/${pid}/dag" class="${tab === 'dag' ? 'active' : ''}">DAG 工作流</a>
|
||||
<a href="#/project/${pid}/kb" class="${tab === 'kb' ? 'active' : ''}">知识库 RAG</a>
|
||||
</div>
|
||||
<div id="tab-body"></div>`;
|
||||
}
|
||||
|
||||
async function refreshProj() {
|
||||
projCtx.tasks = (await api(`/api/tasks?project_id=${projCtx.pid}`)).data;
|
||||
if (projCtx.tab === 'dag') renderDag();
|
||||
else renderKanban();
|
||||
}
|
||||
|
||||
/* --- 看板 --- */
|
||||
function renderKanban() {
|
||||
const {tasks} = kanbanData;
|
||||
const {tasks} = projCtx;
|
||||
const by = {};
|
||||
tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); });
|
||||
$('#tab-body').innerHTML = `<div style="overflow-x:auto"><div class="kanban" id="kanban">${KANBAN_ORDER.map(s => `
|
||||
<div class="kb-col" data-status="${s}">
|
||||
<div class="kb-head"><span><span class="badge ${STATUS[s].cls}">${STATUS[s].label}</span></span>
|
||||
<span class="cnt" id="cnt-${s}">0</span></div>
|
||||
<div class="kb-body" data-drop="${s}"></div>
|
||||
</div>`).join('')}</div></div>`;
|
||||
KANBAN_ORDER.forEach(s => {
|
||||
const list = by[s] || [];
|
||||
$('#cnt-' + s).textContent = list.length;
|
||||
$('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => `
|
||||
<div class="kb-card ${t.status === 'running' ? '' : ''}" draggable="${t.status === 'todo' || t.status === 'rejected' || t.status === 'done'}"
|
||||
<div class="kb-card" draggable="${t.status === 'todo' || t.status === 'rejected' || t.status === 'done'}"
|
||||
data-id="${t.id}" onclick="openTaskDetail(${t.id})">
|
||||
<div class="t">${esc(t.title)}</div>
|
||||
<div class="m">
|
||||
<span class="pri-${t.priority}">●${PRI[t.priority]}</span>
|
||||
${t.worker_id ? `<span class="pill" style="color:var(--accent)">Worker#${t.worker_id}</span>` : '<span class="pill">自动路由</span>'}
|
||||
${t.depends_on && t.depends_on.length ? `<span class="pill" style="color:var(--purple)">依赖×${t.depends_on.length}</span>` : ''}
|
||||
${t.rejection_count ? `<span class="badge st-rejected">打回×${t.rejection_count}</span>` : ''}
|
||||
${t.status === 'running' ? '<span class="spin"></span>' : ''}
|
||||
</div>
|
||||
${t.status === 'done' ? '<div class="out">✅ 已交付 v' + t.output_version + '</div>' : ''}
|
||||
</div>`).join('') || '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px 0">空</div>';
|
||||
});
|
||||
bindKanbanDnD();
|
||||
}
|
||||
|
||||
function bindKanbanDnD() {
|
||||
$$('.kb-body').forEach(bd => {
|
||||
bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); });
|
||||
bd.addEventListener('dragleave', () => bd.classList.remove('drag-over'));
|
||||
bd.addEventListener('drop', e => {
|
||||
e.preventDefault(); bd.classList.remove('drag-over');
|
||||
const tid = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
moveTask(tid, bd.dataset.drop);
|
||||
});
|
||||
});
|
||||
$$('.kb-card[draggable="true"]').forEach(c => {
|
||||
c.addEventListener('dragstart', e => {
|
||||
e.dataTransfer.setData('text/plain', c.dataset.id);
|
||||
@@ -234,76 +276,228 @@ function renderKanban() {
|
||||
}
|
||||
|
||||
async function moveTask(tid, status) {
|
||||
const t = kanbanData.tasks.find(x => x.id === tid);
|
||||
const t = projCtx.tasks.find(x => x.id === tid);
|
||||
if (!t || t.status === status) return;
|
||||
if (['running', 'review'].includes(status)) return toast('不能直接拖到该状态', 'err');
|
||||
if (status === 'todo' && !['rejected', 'failed', 'done'].includes(t.status)) return;
|
||||
if (status === 'done') return toast('完成请走审核流(审核通过)', 'err');
|
||||
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status}});
|
||||
toast('状态已更新', 'ok'); refreshKanban();
|
||||
toast('状态已更新', 'ok'); refreshProj();
|
||||
}
|
||||
|
||||
async function refreshKanban() {
|
||||
const {pid} = kanbanData;
|
||||
kanbanData.tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
|
||||
renderKanban();
|
||||
/* --- 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}); }));
|
||||
|
||||
// 拓扑分层:最长路径
|
||||
const layer = {};
|
||||
const visit = id => {
|
||||
if (layer[id] !== undefined) return layer[id];
|
||||
const t = idMap[id];
|
||||
let l = 0;
|
||||
(t.depends_on || []).forEach(d => { if (idMap[d]) l = Math.max(l, visit(d) + 1); });
|
||||
layer[id] = l;
|
||||
return l;
|
||||
};
|
||||
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)));
|
||||
ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; });
|
||||
});
|
||||
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 = `
|
||||
<div class="toolbar" style="margin-bottom:10px">
|
||||
<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>`;
|
||||
}
|
||||
|
||||
function openTaskModal(pid) {
|
||||
const workers = kanbanData ? kanbanData.workers : [];
|
||||
async function runWorkflow() {
|
||||
const r = await api(`/api/projects/${projCtx.pid}/workflow/run`, {method:'POST'});
|
||||
toast(`🚀 已派发 ${r.started.length} 个任务${r.blocked.length ? `,${r.blocked.length} 个因依赖阻塞` : ''}`, 'ok');
|
||||
setTimeout(refreshProj, 1500);
|
||||
}
|
||||
|
||||
/* --- 知识库 RAG --- */
|
||||
async function renderKb() {
|
||||
const docs = (await api(`/api/projects/${projCtx.pid}/documents`)).data;
|
||||
$('#tab-body').innerHTML = `
|
||||
<div class="toolbar">
|
||||
<span class="pager-tip">文档共 ${docs.length} 篇 · 任务执行时自动检索注入上下文</span>
|
||||
<button class="btn primary" onclick="openDocModal()">+ 添加文档</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label>🔍 检索测试(模拟任务执行时的 RAG 命中)</label>
|
||||
<div class="row"><input id="kb-q" placeholder="输入查询,例如:保温杯卖点"><button class="btn" onclick="kbSearch()">检索</button></div>
|
||||
<div id="kb-results"></div>
|
||||
<div class="sec-title" style="margin-top:16px">文档列表</div>
|
||||
<div id="kb-docs">${docs.map(d => `
|
||||
<div class="doc-item">
|
||||
<div><b>${esc(d.name)}</b> <span class="pill">${d.chunks} 块</span> <span style="color:var(--muted);font-size:12px">${d.content.length} 字 · ${fmtTime(d.created_at)}</span></div>
|
||||
<div><button class="btn sm" onclick="openDocModal(${d.id})">查看/编辑</button>
|
||||
<button class="btn sm danger" onclick="delDoc(${d.id})">删除</button></div>
|
||||
</div>`).join('') || '<div class="empty">暂无文档,添加产品资料/知识手册后 AI Worker 干活时会自动参考</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function kbSearch() {
|
||||
const q = $('#kb-q').value.trim();
|
||||
if (!q) return;
|
||||
const r = await api(`/api/projects/${projCtx.pid}/search?q=${encodeURIComponent(q)}`);
|
||||
$('#kb-results').innerHTML = r.data.length ? r.data.map(h => `
|
||||
<div class="alert-row" style="margin-top:8px">
|
||||
<div class="t">📄 命中 文档#${h.document_id} · 块${h.chunk_id} · 得分 ${h.score}</div>
|
||||
<div class="d">${esc(h.content.slice(0, 160))}${h.content.length > 160 ? '…' : ''}</div>
|
||||
</div>`).join('') : '<div class="empty" style="padding:14px">无命中(知识库为空或关键词不匹配)</div>';
|
||||
}
|
||||
|
||||
function openDocModal(docId) {
|
||||
(docId ? api(`/api/documents/${docId}`) : Promise.resolve({data: {}})).then(async r => {
|
||||
const doc = docId ? r.data : {};
|
||||
openModal(`
|
||||
<h3>${docId ? '编辑文档' : '添加文档'}<button class="close" data-close>×</button></h3>
|
||||
<label>文档名称 *</label><input id="dm-name" value="${esc(doc.name || '')}">
|
||||
<label>内容(支持粘贴文本 / 上传 txt、md 文件)</label>
|
||||
<textarea id="dm-content" style="min-height:220px">${esc(doc.content || '')}</textarea>
|
||||
<input type="file" id="dm-file" accept=".txt,.md" style="margin-top:8px">
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="dm-save">保存并分块索引</button>
|
||||
</div>`);
|
||||
$('#dm-file').addEventListener('change', e => {
|
||||
const f = e.target.files[0];
|
||||
if (!f) return;
|
||||
const rd = new FileReader();
|
||||
rd.onload = () => { $('#dm-content').value = rd.result; if (!$('#dm-name').value) $('#dm-name').value = f.name; };
|
||||
rd.readAsText(f);
|
||||
});
|
||||
$('#dm-save').addEventListener('click', async () => {
|
||||
const name = $('#dm-name').value.trim(), content = $('#dm-content').value;
|
||||
if (!name || !content.trim()) return toast('名称和内容必填', 'err');
|
||||
const rr = await api(docId ? `/api/documents/${docId}` : `/api/projects/${projCtx.pid}/documents`,
|
||||
{method: docId ? 'PUT' : 'POST', body: docId ? {name, content} : {name, content}});
|
||||
toast(docId ? '已保存' : `已添加,分块 ${rr.chunks} 块`, 'ok'); closeModal(); renderKb();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function delDoc(docId) {
|
||||
if (!confirm('确认删除该文档及其分块?')) return;
|
||||
await api(`/api/documents/${docId}`, {method:'DELETE'});
|
||||
toast('已删除', 'ok'); renderKb();
|
||||
}
|
||||
|
||||
/* --- 新建任务(含依赖) --- */
|
||||
function openTaskModal(editTask) {
|
||||
const t = editTask || {};
|
||||
const {tasks, workers, pid} = projCtx || {tasks: [], workers: []};
|
||||
const candidates = tasks.filter(x => x.status !== 'running' && x.id !== t.id);
|
||||
const deps = t.depends_on || [];
|
||||
openModal(`
|
||||
<h3>新建任务<button class="close" data-close>×</button></h3>
|
||||
<label>任务标题 *</label><input id="tm-title" placeholder="例如:生成 10 条小红书种草文案">
|
||||
<label>任务说明(发给 AI Worker 的指令)</label><textarea id="tm-desc" placeholder="描述任务目标、要求、输出格式…"></textarea>
|
||||
<h3>${t.id ? '编辑任务' : '新建任务'}<button class="close" data-close>×</button></h3>
|
||||
<label>任务标题 *</label><input id="tm-title" value="${esc(t.title || '')}" placeholder="例如:生成 10 条小红书种草文案">
|
||||
<label>任务说明(发给 AI Worker 的指令)</label><textarea id="tm-desc" placeholder="描述任务目标、要求、输出格式…">${esc(t.description || '')}</textarea>
|
||||
<div class="row">
|
||||
<div><label>执行 Worker</label><select id="tm-worker">
|
||||
<option value="">🤖 自动路由(最便宜可用)</option>
|
||||
${workers.map(w => `<option value="${w.id}">${esc(w.name)}(${w.provider}/${w.model})</option>`).join('')}
|
||||
${workers.map(w => `<option value="${w.id}" ${t.worker_id === w.id ? 'selected' : ''}>${esc(w.name)}(${w.provider}/${w.model})</option>`).join('')}
|
||||
</select></div>
|
||||
<div><label>优先级</label><select id="tm-pri">
|
||||
${Object.entries(PRI).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||
${Object.entries(PRI).map(([k, v]) => `<option value="${k}" ${t.priority === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>截止时间</label><input id="tm-deadline" type="date"></div>
|
||||
<div><label>完成后人工审核</label><select id="tm-review"><option value="1">需要(HITL)</option><option value="0">不需要</option></select></div>
|
||||
<div><label>截止时间</label><input id="tm-deadline" type="date" value="${t.deadline || ''}"></div>
|
||||
<div><label>完成后人工审核</label><select id="tm-review">
|
||||
<option value="1" ${t.review_required !== 0 ? 'selected' : ''}>需要(HITL)</option>
|
||||
<option value="0" ${t.review_required === 0 ? 'selected' : ''}>不需要</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label>前置任务(DAG 依赖,完成后方可执行)</label>
|
||||
<div class="check-group" id="tm-deps">
|
||||
${candidates.length ? candidates.map(c => `
|
||||
<label><input type="checkbox" value="${c.id}" ${deps.includes(c.id) ? 'checked' : ''}>
|
||||
#${c.id} ${esc(c.title).slice(0, 18)}(${STATUS[c.status].label})</label>`).join('') : '<span style="color:var(--muted);font-size:12px">暂无其他任务可选</span>'}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="tm-save">创建并派活</button>
|
||||
<button class="btn primary" id="tm-save">${t.id ? '保存' : '创建并派活'}</button>
|
||||
</div>`);
|
||||
$('#tm-save').addEventListener('click', async () => {
|
||||
const title = $('#tm-title').value.trim();
|
||||
if (!title) return toast('请填写任务标题', 'err');
|
||||
const body = {
|
||||
title, description: $('#tm-desc').value,
|
||||
worker_id: $('#tm-worker').value ? parseInt($('#tm-worker').value) : null,
|
||||
priority: $('#tm-pri').value, deadline: $('#tm-deadline').value,
|
||||
review_required: $('#tm-review').value === '1',
|
||||
depends_on: $$('#tm-deps input:checked').map(x => parseInt(x.value))
|
||||
};
|
||||
try {
|
||||
const r = await api('/api/tasks', {method:'POST', body:{
|
||||
project_id: pid, title, description: $('#tm-desc').value,
|
||||
worker_id: $('#tm-worker').value ? parseInt($('#tm-worker').value) : null,
|
||||
priority: $('#tm-pri').value, deadline: $('#tm-deadline').value,
|
||||
review_required: $('#tm-review').value === '1'
|
||||
}});
|
||||
toast('任务已创建', 'ok'); closeModal(); refreshKanban();
|
||||
if (confirm('任务已创建,立即派给 AI Worker 执行?')) runTask(r.id);
|
||||
if (t.id) {
|
||||
await api(`/api/tasks/${t.id}`, {method:'PUT', body});
|
||||
toast('已保存', 'ok'); closeModal(); refreshProj();
|
||||
} else {
|
||||
const r = await api('/api/tasks', {method:'POST', body:{...body, project_id: projCtx.pid}});
|
||||
toast('任务已创建', 'ok'); closeModal(); refreshProj();
|
||||
if (confirm('任务已创建,立即派给 AI Worker 执行?')) runTask(r.id);
|
||||
}
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function runTask(tid) {
|
||||
try { await api(`/api/tasks/${tid}/run`, {method:'POST'}); toast('🚀 已派活,AI Worker 开始执行', 'ok'); refreshKanban(); }
|
||||
try { await api(`/api/tasks/${tid}/run`, {method:'POST'}); toast('🚀 已派活,AI Worker 开始执行', 'ok'); refreshProj(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
/* ---------- 任务详情(含审核) ---------- */
|
||||
async function openTaskDetail(tid) {
|
||||
const t = (await api(`/api/tasks/${tid}`)).data;
|
||||
const workers = kanbanData ? kanbanData.workers : (await api('/api/workers')).data;
|
||||
const workers = projCtx?.workers || (await api('/api/workers')).data;
|
||||
const w = t.worker || (t.worker_id ? {id: t.worker_id, name: `#${t.worker_id}`} : null);
|
||||
const deps = (t.depends_on || []).map(d => projCtx?.tasks.find(x => x.id === d)).filter(Boolean);
|
||||
openModal(`
|
||||
<h3>${esc(t.title)}<span class="badge ${STATUS[t.status].cls}" style="margin-left:8px">${STATUS[t.status].label}</span>
|
||||
<button class="close" data-close>×</button></h3>
|
||||
<div class="kv" style="margin-bottom:6px">
|
||||
<dt>执行 Worker</dt><dd>${w ? `${esc(w.name)} <span class="pill">${esc(w.provider)}/${esc(w.model)}</span>` : '未指派(自动路由)'}</dd>
|
||||
<dt>优先级</dt><dd><span class="pri-${t.priority}">●${PRI[t.priority]}</span> · 返工 ${t.rejection_count} 次</dd>
|
||||
<dt>前置任务</dt><dd>${deps.length ? deps.map(d => `<span class="pill" style="color:var(--purple)" onclick="openTaskDetail(${d.id})">#${d.id} ${esc(d.title).slice(0,12)}(${STATUS[d.status].label})</span>`).join('') : '无'}</dd>
|
||||
<dt>创建/更新</dt><dd class="mono">${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}</dd>
|
||||
<dt>截止</dt><dd>${t.deadline || '—'}</dd>
|
||||
</div>
|
||||
@@ -314,14 +508,16 @@ async function openTaskDetail(tid) {
|
||||
${t.error ? `<div class="sec-title" style="color:var(--danger)">错误信息</div>
|
||||
<div class="output-box" style="border-color:var(--danger)">${esc(t.error)}</div>` : ''}
|
||||
<div class="sec-title">执行日志</div>
|
||||
<div id="td-logs">${t.logs.map(l => logLine(l)).join('') || '<div class="empty">暂无日志</div>'}</div>
|
||||
<div id="td-logs">${t.logs.map(logLine).join('') || '<div class="empty">暂无日志</div>'}</div>
|
||||
<div class="modal-actions" id="td-actions">
|
||||
${t.status === 'todo' ? `<button class="btn primary" onclick="runTask(${t.id})">🚀 派活执行</button>
|
||||
<button class="btn" onclick="openTaskModal(${JSON.stringify(t).replace(/"/g, '"')})">编辑</button>
|
||||
<button class="btn danger" onclick="delTask(${t.id})">删除</button>` : ''}
|
||||
${t.status === 'running' ? `<button class="btn warn" onclick="cancelTask(${t.id})">⏹ 取消执行</button>` : ''}
|
||||
${t.status === 'review' ? `<button class="btn success" onclick="reviewTask(${t.id}, 'approve')">✅ 审核通过</button>
|
||||
<button class="btn warn" onclick="reviewTask(${t.id}, 'reject')">⛔ 打回返工</button>` : ''}
|
||||
${t.status === 'failed' ? `<button class="btn primary" onclick="runTask(${t.id})">🔄 重试</button>` : ''}
|
||||
${t.status === 'failed' ? `<button class="btn primary" onclick="runTask(${t.id})">🔄 重试</button>
|
||||
<button class="btn" onclick="openTaskModal(${JSON.stringify(t).replace(/"/g, '"')})">编辑</button>` : ''}
|
||||
${['done','cancelled'].includes(t.status) ? `<button class="btn" onclick="reopenTask(${t.id})">↩️ 重新打开</button>` : ''}
|
||||
</div>`);
|
||||
}
|
||||
@@ -341,22 +537,70 @@ async function reviewTask(tid, action) {
|
||||
await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}});
|
||||
}
|
||||
toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok');
|
||||
closeModal(); refreshKanban();
|
||||
closeModal(); refreshProj();
|
||||
}
|
||||
|
||||
async function cancelTask(tid) {
|
||||
if (!confirm('确认取消该任务?')) return;
|
||||
await api(`/api/tasks/${tid}/cancel`, {method:'POST'});
|
||||
toast('已取消', 'ok'); closeModal(); refreshKanban();
|
||||
toast('已取消', 'ok'); closeModal(); refreshProj();
|
||||
}
|
||||
async function reopenTask(tid) {
|
||||
await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}});
|
||||
toast('任务已重新打开', 'ok'); closeModal(); refreshKanban();
|
||||
toast('任务已重新打开', 'ok'); closeModal(); refreshProj();
|
||||
}
|
||||
async function delTask(tid) {
|
||||
if (!confirm('确认删除该任务及其日志/成本记录?')) return;
|
||||
await api(`/api/tasks/${tid}`, {method:'DELETE'});
|
||||
toast('已删除', 'ok'); closeModal(); refreshKanban();
|
||||
toast('已删除', 'ok'); closeModal(); refreshProj();
|
||||
}
|
||||
|
||||
/* ---------- AI 规划 WBS ---------- */
|
||||
function openWbsModal() {
|
||||
const {p} = projCtx;
|
||||
openModal(`
|
||||
<h3>🧠 AI 辅助规划(WBS 任务分解)<button class="close" data-close>×</button></h3>
|
||||
<label>项目目标(默认取项目 objective)</label>
|
||||
<textarea id="wb-goal">${esc(p.objective || p.name || '')}</textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="wb-gen">生成任务分解</button>
|
||||
</div>
|
||||
<div id="wb-result"></div>`);
|
||||
$('#wb-gen').addEventListener('click', async () => {
|
||||
const goal = $('#wb-goal').value.trim();
|
||||
if (!goal) return toast('请填写项目目标', 'err');
|
||||
$('#wb-result').innerHTML = '<div class="empty"><span class="spin"></span>AI 正在拆解任务(约 10-30 秒)…</div>';
|
||||
try {
|
||||
const r = await api(`/api/projects/${projCtx.pid}/wbs/generate`, {method:'POST', body:{goal}});
|
||||
let wbs = r.data;
|
||||
$('#wb-result').innerHTML = `
|
||||
<div class="sec-title" style="margin-top:16px">生成 ${wbs.length} 个任务(可编辑后导入)· 花费 ¥${r.cost.toFixed(4)} / ${r.usage} tokens</div>
|
||||
${wbs.map((t, i) => `
|
||||
<div class="wbs-row">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span class="idx">${i+1}</span>
|
||||
<input value="${esc(t.title || '')}" style="flex:1;font-weight:600" data-f="title" data-i="${i}">
|
||||
</div>
|
||||
<textarea style="margin-top:8px" data-f="desc" data-i="${i}">${esc(t.description || '')}</textarea>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:6px">依赖任务:${wbs.map((_, j) => `<label style="margin:0 6px 0 0"><input type="checkbox" data-dep="${i}" value="${j}" ${(t.depends_on||[]).includes(j) ? 'checked' : ''}>#${j+1}</label>`).join('')}</div>
|
||||
</div>`).join('')}
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="wb-import">✅ 导入为项目任务</button>
|
||||
</div>`;
|
||||
$('#wb-import').addEventListener('click', async () => {
|
||||
const tasks = wbs.map((t, i) => ({
|
||||
title: $(`[data-f="title"][data-i="${i}"]`).value.trim() || t.title,
|
||||
description: $(`[data-f="desc"][data-i="${i}"]`).value,
|
||||
depends_on: $$(`[data-dep="${i}"]:checked`).map(x => parseInt(x.value))
|
||||
}));
|
||||
const rr = await api(`/api/projects/${projCtx.pid}/wbs/import`, {method:'POST', body:{tasks, worker_id: projCtx.workers[0]?.id || null}});
|
||||
toast(`✅ 已导入 ${rr.created} 个任务(含依赖关系)`, 'ok');
|
||||
closeModal(); refreshProj(); router();
|
||||
});
|
||||
} catch (e) { $('#wb-result').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Worker 管理 ---------- */
|
||||
@@ -493,6 +737,170 @@ async function pageLogs() {
|
||||
[任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}</div>`).join('') || '<div class="empty">暂无日志</div>'}</div>`;
|
||||
}
|
||||
|
||||
/* ---------- 告警中心 ---------- */
|
||||
async function pageAlerts() {
|
||||
const alerts = (await api('/api/alerts?limit=200')).data;
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">告警中心<small>任务失败 · 预算超标 · Worker 异常 · 成本拦截</small></h1>
|
||||
<div class="toolbar"><button class="btn" onclick="readAllAlerts()">✔ 全部标记已读</button></div>
|
||||
<div class="card">${alerts.map(a => `
|
||||
<div class="alert-row ${a.read ? '' : 'unread'} ${a.level === 'critical' ? 'critical' : ''}" onclick="readAlert(${a.id})">
|
||||
<div class="t">${ALERT_LEVEL[a.level] || 'ℹ️'} ${esc(a.title)} ${a.read ? '' : '<span class="badge st-review">未读</span>'}</div>
|
||||
<div class="d">${esc(a.detail)}</div>
|
||||
<div class="tm">${fmtTime(a.created_at)} · ${esc(a.type)}</div>
|
||||
</div>`).join('') || '<div class="empty">暂无告警 🎉</div>'}</div>`;
|
||||
refreshAlertBadge();
|
||||
}
|
||||
async function readAlert(id) {
|
||||
await api(`/api/alerts/${id}/read`, {method:'POST'});
|
||||
pageAlerts();
|
||||
}
|
||||
async function readAllAlerts() {
|
||||
await api('/api/alerts/read_all', {method:'POST'});
|
||||
toast('已全部标记为已读', 'ok'); pageAlerts();
|
||||
}
|
||||
|
||||
/* ---------- 开放 API ---------- */
|
||||
async function pageApiTokens() {
|
||||
const tokens = (await api('/api/tokens')).data;
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">开放 API<small>外部系统通过 Bearer Token 调平台能力</small></h1>
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" onclick="createToken()">+ 生成 API Token</button>
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">已生成的 Token</div>
|
||||
<table><thead><tr><th>名称</th><th>创建时间</th><th>最近使用</th><th>操作</th></tr></thead><tbody>
|
||||
${tokens.map(t => `<tr><td><b>${esc(t.name)}</b></td><td class="mono">${fmtTime(t.created_at)}</td>
|
||||
<td class="mono">${t.last_used_at ? fmtTime(t.last_used_at) : '从未使用'}</td>
|
||||
<td><button class="btn sm danger" onclick="delToken(${t.id})">删除</button></td></tr>`).join('') || '<tr><td colspan="4" class="empty">暂无 Token</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">调用示例</div>
|
||||
<div style="font-size:13px;line-height:2">
|
||||
<div><code>POST /api/tasks</code> 创建任务</div>
|
||||
<pre class="output-box" style="font-size:12px">curl -X POST http://<host>:16071/api/tasks \\
|
||||
-H "Authorization: Bearer <TOKEN>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"project_id":1,"title":"生成周报","description":"…"}'
|
||||
# 响应: {"ok":true,"id":42}</pre>
|
||||
<div><code>POST /api/tasks/42/run</code> 派活执行</div>
|
||||
<pre class="output-box" style="font-size:12px">curl -X POST http://<host>:16071/api/tasks/42/run \\
|
||||
-H "Authorization: Bearer <TOKEN>"</pre>
|
||||
<div><code>GET /api/tasks/42</code> 查结果(status=review 时人工审核)</div>
|
||||
<div><code>POST /api/tasks/42/review</code> 审核</div>
|
||||
<pre class="output-box" style="font-size:12px">curl -X POST http://<host>:16071/api/tasks/42/review \\
|
||||
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
|
||||
-d '{"action":"approve"}' # 或 {"action":"reject","reason":"…"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const name = prompt('Token 名称(用途说明):') || 'default';
|
||||
const r = await api('/api/tokens', {method:'POST', body:{name}});
|
||||
alert('✅ 生成成功(只显示一次,请立即复制保存):\n\n' + r.token);
|
||||
pageApiTokens();
|
||||
}
|
||||
async function delToken(id) {
|
||||
if (!confirm('删除后使用该 Token 的调用将失效,确认?')) return;
|
||||
await api(`/api/tokens/${id}`, {method:'DELETE'});
|
||||
toast('已删除', 'ok'); pageApiTokens();
|
||||
}
|
||||
|
||||
/* ---------- 通知设置 ---------- */
|
||||
async function pageSettings() {
|
||||
const [chans, evs, st] = await Promise.all([api('/api/channels'), api('/api/events'), api('/api/settings')]);
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">通知与设置<small>飞书 / 企微群机器人 · 邮件 · 预算告警阈值</small></h1>
|
||||
<div class="toolbar"><button class="btn primary" onclick="openChannelModal(null, ${JSON.stringify(evs.data)})">+ 添加通知渠道</button></div>
|
||||
<div class="grid g2">
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">通知渠道</div>
|
||||
<table><thead><tr><th>名称</th><th>类型</th><th>订阅事件</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${chans.data.map(c => `<tr>
|
||||
<td><b>${esc(c.name)}</b></td>
|
||||
<td><span class="pill">${c.type === 'feishu' ? '飞书' : c.type === 'wecom' ? '企微' : '邮件'}</span></td>
|
||||
<td>${(c.events || []).map(e => `<span class="pill">${(evs.data.find(x => x.id === e) || {}).name || e}</span>`).join('') || '—'}</td>
|
||||
<td><span class="badge ${c.enabled ? 'st-done' : 'st-cancelled'}">${c.enabled ? '启用' : '停用'}</span></td>
|
||||
<td><button class="btn sm" onclick="testChannel(${c.id})">测试</button>
|
||||
<button class="btn sm" onclick="openChannelModal(${JSON.stringify(c).replace(/"/g,'"')}, ${JSON.stringify(evs.data)})">编辑</button>
|
||||
<button class="btn sm danger" onclick="delChannel(${c.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="5" class="empty">暂无渠道,添加飞书/企微机器人后任务事件自动推送</td></tr>'}
|
||||
</tbody></table>
|
||||
<div class="tab-note" style="margin-top:10px">💡 飞书群机器人:群设置 → 群机器人 → 添加自定义机器人,复制 Webhook 地址(以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头)。企微同理(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">预算告警</div>
|
||||
<label>项目预算使用率告警阈值(0~1,达到即告警)</label>
|
||||
<div class="row"><input id="st-ratio" type="number" step="0.05" min="0.1" max="1" value="${st.data.budget_alert_ratio}">
|
||||
<button class="btn primary" onclick="saveSettings()">保存</button></div>
|
||||
<div class="tab-note" style="margin-top:12px">告警会写入告警中心并推送到订阅了「预算告警」事件的渠道。</div>
|
||||
<div class="sec-title">邮件通知</div>
|
||||
<div class="tab-note">${st.data.email_configured ? '✅ 已配置 SMTP(config.py EMAIL)' : '⚠️ 未配置 SMTP(config.py 的 EMAIL 填写 host/user/password 后重启生效)'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
async function saveSettings() {
|
||||
await api('/api/settings', {method:'PUT', body:{budget_alert_ratio: parseFloat($('#st-ratio').value)}});
|
||||
toast('已保存', 'ok');
|
||||
}
|
||||
async function testChannel(id) {
|
||||
toast('正在发送测试消息…');
|
||||
try { const r = await api(`/api/channels/${id}/test`, {method:'POST'});
|
||||
r.ok ? toast('✅ 测试消息已送达', 'ok') : toast('❌ ' + r.msg, 'err'); }
|
||||
catch (e) { toast('❌ ' + e.message, 'err'); }
|
||||
}
|
||||
async function delChannel(id) {
|
||||
if (!confirm('确认删除该渠道?')) return;
|
||||
await api(`/api/channels/${id}`, {method:'DELETE'});
|
||||
toast('已删除', 'ok'); pageSettings();
|
||||
}
|
||||
function openChannelModal(c, events) {
|
||||
c = c || {};
|
||||
openModal(`
|
||||
<h3>${c.id ? '编辑通知渠道' : '添加通知渠道'}<button class="close" data-close>×</button></h3>
|
||||
<label>渠道名称 *</label><input id="cm-name" value="${esc(c.name || '')}" placeholder="例如:项目群飞书机器人">
|
||||
<label>类型</label><select id="cm-type">
|
||||
<option value="feishu" ${c.type === 'feishu' ? 'selected' : ''}>飞书群机器人</option>
|
||||
<option value="wecom" ${c.type === 'wecom' ? 'selected' : ''}>企业微信群机器人</option>
|
||||
<option value="email" ${c.type === 'email' ? 'selected' : ''}>邮件</option>
|
||||
</select>
|
||||
<div id="cm-webhook-wrap"><label>Webhook 地址</label><input id="cm-webhook" value="${esc(c.webhook || '')}"></div>
|
||||
<div id="cm-email-wrap" style="display:none"><label>收件邮箱</label><input id="cm-email" value="${esc(c.email || '')}"></div>
|
||||
<label>订阅事件</label>
|
||||
<div class="check-group">
|
||||
${events.map(e => `<label><input type="checkbox" value="${e.id}" ${(c.events || []).includes(e.id) ? 'checked' : ''}>${esc(e.name)}</label>`).join('')}
|
||||
</div>
|
||||
<label>启用</label><div><label class="switch"><input type="checkbox" id="cm-enabled" ${c.enabled !== 0 ? 'checked' : ''}> 接收推送</label></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-close>取消</button>
|
||||
<button class="btn primary" id="cm-save">保存</button>
|
||||
</div>`);
|
||||
const toggle = () => {
|
||||
const isMail = $('#cm-type').value === 'email';
|
||||
$('#cm-webhook-wrap').style.display = isMail ? 'none' : '';
|
||||
$('#cm-email-wrap').style.display = isMail ? '' : 'none';
|
||||
};
|
||||
$('#cm-type').addEventListener('change', toggle);
|
||||
toggle();
|
||||
$('#cm-save').addEventListener('click', async () => {
|
||||
const body = {
|
||||
name: $('#cm-name').value.trim(), type: $('#cm-type').value,
|
||||
webhook: $('#cm-webhook').value.trim(), email: $('#cm-email').value.trim(),
|
||||
events: $$('#modal-box .check-group input:checked').map(x => x.value),
|
||||
enabled: $('#cm-enabled').checked ? 1 : 0
|
||||
};
|
||||
if (!body.name || (body.type !== 'email' && !body.webhook) || (body.type === 'email' && !body.email))
|
||||
return toast('请填写完整', 'err');
|
||||
await api(c.id ? `/api/channels/${c.id}` : '/api/channels', {method: c.id ? 'PUT' : 'POST', body});
|
||||
toast('已保存', 'ok'); closeModal(); pageSettings();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
(async function init() {
|
||||
try {
|
||||
@@ -501,5 +909,6 @@ async function pageLogs() {
|
||||
$('#logout-btn').style.display = 'block';
|
||||
} catch (e) { showLogin(); return; }
|
||||
try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; }
|
||||
refreshAlertBadge();
|
||||
router();
|
||||
})();
|
||||
@@ -16,6 +16,9 @@
|
||||
<a href="#/workers" data-route="workers">🧑💻 AI Worker</a>
|
||||
<a href="#/reports" data-route="reports">💰 成本报表</a>
|
||||
<a href="#/logs" data-route="logs">📜 运行日志</a>
|
||||
<a href="#/alerts" data-route="alerts">🚨 告警中心 <span id="alert-badge" class="badge st-review" style="display:none;float:right">0</span></a>
|
||||
<a href="#/api" data-route="api">🔌 开放 API</a>
|
||||
<a href="#/settings" data-route="settings">⚙️ 通知设置</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div id="conn-state" class="conn ok">● 服务正常</div>
|
||||
|
||||
@@ -126,3 +126,33 @@ tr:hover td{background:var(--panel2)}
|
||||
.proj-card:hover{border-color:var(--accent)}
|
||||
.pager-tip{color:var(--muted);font-size:12px}
|
||||
.pill{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:12px;margin:2px}
|
||||
|
||||
/* V1:tabs / DAG / 告警 / 开关 */
|
||||
.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-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-edge.act{stroke: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)}
|
||||
.alert-row .t{font-weight:600;font-size:13px}
|
||||
.alert-row .d{color:var(--muted);font-size:12px;margin-top:4px}
|
||||
.alert-row .tm{color:var(--muted);font-size:11px;margin-top:4px}
|
||||
.alert-row .badge{margin-right:8px}
|
||||
.check-group{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
|
||||
.check-group label{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--text);margin:0;padding:5px 10px;background:var(--panel2);border:1px solid var(--border);border-radius:20px;cursor:pointer}
|
||||
.check-group input{width:auto}
|
||||
.switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px}
|
||||
.switch input{width:auto}
|
||||
code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 7px;font-size:12px;color:var(--accent2);font-family:ui-monospace,Consolas,monospace}
|
||||
.doc-item{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.wbs-row{border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px;background:var(--panel2)}
|
||||
.wbs-row .idx{display:inline-block;width:26px;height:26px;line-height:26px;text-align:center;background:var(--accent);color:#fff;border-radius:50%;font-size:13px;margin-right:8px}
|
||||
.copy-btn{cursor:pointer;color:var(--accent)}
|
||||
.tab-note{color:var(--muted);font-size:12px;margin-bottom:12px}
|
||||
Reference in New Issue
Block a user