V3.4 负责人验收制+邮件修复+事件面板+真实网页交付

- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因)
- 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收
- 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填
- 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位
- 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME
- 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人
- 项目状态新增 review(待验收),全端展示
This commit is contained in:
2026-08-14 13:33:59 +08:00
parent 3b5c06fdd9
commit 7c4cbf8994
10 changed files with 514 additions and 42 deletions
+71 -11
View File
@@ -163,7 +163,7 @@ async function pageProjects() {
${list.map(p => `
<div class="card proj-card" onclick="location.hash='#/project/${p.id}'">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'review' ? 'review' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
</div>
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
<div style="margin-top:10px;font-size:12px;color:var(--muted)">任务 ${p.task_count} · 完成 ${p.done_count}
@@ -187,7 +187,7 @@ function openProjectModal(p = {}) {
<label>项目描述</label><textarea id="pm-desc">${esc(p.description || '')}</textarea>
<div class="row">
<div><label>状态</label><select id="pm-status">
${['planning','active','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
${['planning','active','review','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
</select></div>
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
</div>
@@ -277,8 +277,8 @@ 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;
const autoLogs = (await api(`/api/projects/${pid}/autologs`)).data;
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban', autoLogs};
const events = (await api(`/api/projects/${pid}/events`)).data;
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban', events};
renderProjectShell();
renderActiveTab();
startProjAutoPoll();
@@ -291,7 +291,11 @@ function renderActiveTab() {
else renderKanban();
}
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/动态/看板 */
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/事件/看板 */
async function refreshProjEvents() {
try { projCtx.events = (await api(`/api/projects/${projCtx.pid}/events`)).data; } catch (e) {}
}
function startProjAutoPoll() {
clearInterval(projAutoTimer);
renderAutoPanel();
@@ -301,7 +305,7 @@ function startProjAutoPoll() {
const wasRunning = projCtx.p.auto_status === 'running';
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
await refreshProjEvents();
const running = p.auto_status === 'running';
// 状态变化(开工结束/中断)或运行中:都刷新一次任务视图
if (running || wasRunning) {
@@ -320,12 +324,43 @@ async function autoStartProj() {
toast('🚀 AI 主管已开工,正在拆解派活…', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
await refreshProjEvents();
renderAutoPanel();
startProjAutoPoll();
} catch (e) { toast(e.message, 'err'); }
}
/* V3.4:负责人验收(平台内) */
function openReviewModal() {
const {p} = projCtx;
openModal(`
<h3>🔔 项目验收:${esc(p.name)}<button class="close" data-close>×</button></h3>
<p style="color:var(--muted);font-size:13px">AI 团队已全部完工,由您(负责人)最终验收。<br>验收通过后项目才算正式完成,将自动打包交付。</p>
${p.demo_url ? `<p><a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent)">👀 查看交付 Demo</a></p>` : ''}
<label>验收意见(打回必填)</label><textarea id="rv-reason" placeholder="填写验收意见…"></textarea>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn danger" onclick="submitProjectReview('reject')">⛔ 打回返工</button>
<button class="btn primary" onclick="submitProjectReview('approve')">✅ 验收通过</button>
</div>`);
}
async function submitProjectReview(action) {
const reason = $('#rv-reason').value.trim();
if (action === 'reject' && !reason) return toast('打回必须填写原因', 'err');
try {
await api(`/api/projects/${projCtx.pid}/review`, {method: 'POST', body: {action, reason}});
closeModal();
toast(action === 'approve' ? '✅ 验收通过,项目完成并交付' : '⛔ 已打回,可重新开工返工', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
await refreshProjEvents();
renderProjectShell();
renderActiveTab();
renderAutoPanel();
} catch (e) { toast(e.message, 'err'); }
}
function renderAutoPanel() {
const el = $('#auto-panel');
if (!el || !projCtx) return;
@@ -334,7 +369,15 @@ function renderAutoPanel() {
const st = {none:['未开工','todo'], running:['🚀 AI 主管工作中','running'], done:['✅ 自动开工完成','done'], failed:['⚠️ 开工中断','failed']}[p.auto_status] || ['—','todo'];
const mgr = p.manager_worker ? esc(p.manager_worker.name) : '<span style="color:var(--warn)">未设置</span>';
const team = (p.team_workers || []).map(w => `<span class="pill" style="color:var(--accent)">${esc(w.name)}</span>`).join(' ') || '<span style="color:var(--warn)">未设置</span>';
const logs = (projCtx.autoLogs || []).map(l => `<div class="al-item"><span class="al-lv">${l.level === 'error' ? '❌' : l.level === 'warn' ? '⚠️' : l.level === 'success' ? '✅' : '🤖'}</span><span class="al-msg">${esc(l.message)}</span><span class="al-time">${fmtTime(l.created_at)}</span></div>`).join('');
const evs = projCtx.events || [];
const collapsed = localStorage.getItem('aw_ev_collapsed_' + projCtx.pid) === '1';
const icon = l => ({error:'❌', warn:'⚠️', success:'✅', info:'🤖'}[l] || '•');
const items = evs.map(e => `
<div class="al-item ${e.task_id ? 'al-task' : ''}" ${e.task_id ? `onclick="openTaskDetail(${e.task_id})" title="点击定位任务 #${e.task_id}"` : ''}>
<span class="al-lv">${icon(e.level)}</span>
<span class="al-msg">${esc(e.message)}${e.task_id ? `<span class="al-go">任务 #${e.task_id} </span>` : ''}</span>
<span class="al-time">${fmtTime(e.created_at)}</span>
</div>`).join('') || '<div class="empty" style="padding:12px">暂无事件</div>';
el.innerHTML = `
<div class="auto-bar">
<span class="badge st-${st[1]}">${st[0]}</span>
@@ -342,12 +385,29 @@ function renderAutoPanel() {
${running ? '<span class="spin"></span>' : ''}
</div>
<div class="auto-meta">👔 AI 主管:${mgr} 👷 干活团队:${team} ${p.auto_started_at ? '开工于 ' + fmtTime(p.auto_started_at) : '—'}</div>
${logs ? `<div class="auto-logs">${logs}</div>` : ''}`;
<div class="ev-panel ${collapsed ? 'collapsed' : ''}">
<div class="ev-head" onclick="toggleEvents()">
<span class="ev-arrow">${collapsed ? '▸' : '▾'}</span>
<span>📋 事件记录</span>
<span class="ev-count">${evs.length} 条</span>
<span class="ev-hint">${collapsed ? '点击展开' : '点击收起'}</span>
</div>
<div class="ev-body">${items}</div>
</div>`;
}
function toggleEvents() {
const panel = $('#auto-panel .ev-panel');
if (!panel) return;
const collapsed = panel.classList.contains('collapsed');
localStorage.setItem('aw_ev_collapsed_' + projCtx.pid, collapsed ? '0' : '1');
renderAutoPanel();
}
function renderProjectShell() {
const {p, pid, tab} = projCtx;
const running = p.auto_status === 'running';
const inReview = p.status === 'review';
$('#main').innerHTML = `
<div class="toolbar">
<a class="btn" href="#/projects">← 返回</a>
@@ -355,7 +415,7 @@ function renderProjectShell() {
${p.perm === 'view' ? '' : `<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '&quot;')})">编辑项目</button>`}
${p.perm === 'view' ? '' : `<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>`}
${p.perm === 'view' ? '' : `<button class="btn" onclick="openTaskModal()"> 新建任务</button>`}
${p.perm === 'view' || running ? '' : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`}
${p.perm === 'view' || running ? '' : (inReview ? `<button class="btn primary" onclick="openReviewModal()">🔔 验收项目</button>` : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`)}
</div>
<div class="tabs">
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
@@ -2542,7 +2602,7 @@ async function entGrantUser(uid, uname) {
<div class="sec-title" style="margin-top:14px">📁 项目授权(${projects.length} 个)</div>
<table style="max-height:220px;overflow:auto;display:block"><tbody>
${projects.map(p => `
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',review:'待验收',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
<td style="width:140px"><select data-gp="${p.id}">
${PROJ_PERMS.map(([v, l]) => `<option value="${v}" ${p.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
</select></td></tr>`).join('') || '<tr><td class="empty">暂无项目</td></tr>'}
+12 -1
View File
@@ -208,9 +208,20 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.pick-item small{color:var(--muted)}
.auto-bar{display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:10px 14px;margin-bottom:8px}
.auto-meta{font-size:12px;color:var(--muted);padding:0 4px 8px}
.auto-logs{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:8px 12px;margin-bottom:14px;max-height:180px;overflow-y:auto}
.ev-panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;margin-bottom:14px;overflow:hidden}
.ev-head{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
.ev-head:hover{background:var(--panel2)}
.ev-arrow{color:var(--accent);font-size:12px;transition:transform .15s}
.ev-count{background:var(--panel2);border:1px solid var(--border);border-radius:20px;padding:0 8px;font-size:11px;color:var(--muted)}
.ev-hint{margin-left:auto;color:var(--muted);font-size:11px;font-weight:400}
.ev-body{max-height:320px;overflow-y:auto;padding:6px 12px 10px;border-top:1px solid var(--border)}
.ev-panel.collapsed .ev-body{display:none}
.ev-panel.collapsed .ev-arrow{transform:rotate(-90deg)}
.al-item{display:flex;gap:8px;align-items:baseline;font-size:12px;padding:4px 0;border-bottom:1px dashed var(--border)}
.al-item:last-child{border-bottom:none}
.al-lv{flex:none}
.al-msg{flex:1;word-break:break-all}
.al-time{flex:none;color:var(--muted);font-size:11px}
.al-task{cursor:pointer}
.al-task:hover .al-msg{color:var(--accent)}
.al-go{color:var(--accent);font-size:11px;margin-left:6px;border:1px solid var(--border);border-radius:4px;padding:0 5px}