feat(web+store): needs_attention 任务显示在审核区,附失败原因

- pendingApprovals 扩展到包含 needs_attention,subquery 取最近 executor 失败 error
- Task 类型增加 lastRunError 字段,mappers 映射
- 审核区(renderGates)将 needs_attention 渲染为独立橙色卡片,
  展示「需人工确认」标签 + 具体失败原因 + 重新排队/取消按钮
- i18n zh/en 补充 attnLabel/attnReason/attnRequeue/attnCancel/toastRequeued

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-14 14:46:21 +08:00
parent 4a2b4e1094
commit 433b2dc232
5 changed files with 59 additions and 6 deletions
+46 -4
View File
@@ -29,6 +29,8 @@ const I18N = {
agentSection: 'Agent 执行', agentEmpty: '无 agent 在执行', running: 'RUNNING', since: '开始',
usageUnavail: '额度信息不可用', quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置',
gateSection: '审批闸 · 等待裁决', gatePreview: '⛶ 预览',
attnLabel: '需人工确认', attnReason: '失败原因', attnNoReason: '失败原因未记录',
attnRequeue: '↺ 重新排队', attnCancel: '✕ 取消任务',
gateAcceptMerge: '✓ 通过并合并', gateAccept: '✓ 接受', gateOnlyPass: '仅通过',
gateMergeNote: '通过 = 自动把任务分支合并到 {branch}(仅本地合并,不 push',
gateReject: '✗ 拒绝', rejectPlaceholder: '驳回意见(必填)——说明问题与改进方向',
@@ -111,7 +113,7 @@ const I18N = {
toastSaved: '配置已保存',
toastSynced: '同步完成:新建 {created} · 推进 done {done} · 跳过 {skipped}',
toastWsSynced: 'todo 同步:新建 {created} · 推进 done {done} · 跳过 {skipped}',
toastCancelled: '任务已取消', toastDeleted: '任务已删除',
toastCancelled: '任务已取消', toastRequeued: '任务已重新排队', toastDeleted: '任务已删除',
toastDeletedWithSubs: '任务已删除(含 {n} 子孙)',
toastArchiveDeleted: '归档任务已删除', toastArchiveDeletedSubs: '归档任务已删除(含 {n} 子孙)',
toastDepsUpdated: '依赖已更新({n} 项,按依赖重算 ready/blocked',
@@ -160,6 +162,8 @@ const I18N = {
agentSection: 'Agent runs', agentEmpty: 'No agents running', running: 'RUNNING', since: '',
usageUnavail: 'Usage info unavailable', quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}',
gateSection: 'Approval gates · awaiting decision', gatePreview: '⛶ Preview',
attnLabel: 'Needs attention', attnReason: 'Failure reason', attnNoReason: 'No failure reason recorded',
attnRequeue: '↺ Re-queue', attnCancel: '✕ Cancel task',
gateAcceptMerge: '✓ Accept & merge', gateAccept: '✓ Accept', gateOnlyPass: 'Pass only',
gateMergeNote: 'Accept = merge task branch into {branch} (local only, no push)',
gateReject: '✗ Reject', rejectPlaceholder: 'Rejection feedback (required) — describe issues',
@@ -242,7 +246,7 @@ const I18N = {
toastSaved: 'Config saved',
toastSynced: 'Synced: {created} created · {done} advanced · {skipped} skipped',
toastWsSynced: 'Todo sync: {created} created · {done} advanced · {skipped} skipped',
toastCancelled: 'Task cancelled', toastDeleted: 'Task deleted',
toastCancelled: 'Task cancelled', toastRequeued: 'Task re-queued', toastDeleted: 'Task deleted',
toastDeletedWithSubs: 'Task deleted ({n} descendants)',
toastArchiveDeleted: 'Archive deleted', toastArchiveDeletedSubs: 'Archive deleted ({n} descendants)',
toastDepsUpdated: 'Dependencies updated ({n} items, ready/blocked recalculated)',
@@ -859,7 +863,11 @@ function renderGates() {
if (ico) gateHead.appendChild(ico);
gateHead.appendChild(document.createTextNode(' ' + t('gateSection')));
}
$('#gateList').innerHTML = S.approvals.map((ap) => {
const gateApprovals = S.approvals.filter((ap) => ap.status !== 'needs_attention');
const attnTasks = S.approvals.filter((ap) => ap.status === 'needs_attention');
const gateCards = gateApprovals.map((ap) => {
const gate = GATE_OF[ap.status];
const rejOpen = S.rejectOpen.has(ap.id);
const mergeNote = gate === 'exec' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : '';
@@ -888,7 +896,31 @@ function renderGates() {
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('collapse')}</button>
</div>` : ''}
</div>`;
}).join('');
});
const attnCards = attnTasks.map((ap) => {
const reason = ap.lastRunError || t('attnNoReason');
return `
<div class="gate-card gate-card-attn">
<div class="gate-card-head">
<span class="gate-kind gate-kind-attn">${t('attnLabel')}</span>
<span class="gate-title">${esc(ap.title)}</span>
${cplxBadge(ap.complexity)}
${statusChip(ap.status)}
</div>
<div class="gate-body">
<div class="gate-doc-label">${t('attnReason')}</div>
<pre class="doc attn-reason">${esc(reason)}</pre>
</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="task-requeue" data-id="${esc(ap.id)}">${t('attnRequeue')}</button>
<button class="btn btn-reject" data-action="task-cancel" data-id="${esc(ap.id)}">${t('attnCancel')}</button>
<span class="proj-meta">id ${esc(ap.id)} · ${fmtTime(ap.updatedAt)}</span>
</div>
</div>`;
});
$('#gateList').innerHTML = [...attnCards, ...gateCards].join('');
}
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
@@ -1738,6 +1770,16 @@ document.addEventListener('click', (ev) => {
break;
}
case 'task-requeue':
act(() => api(`/api/tasks/${id}/requeue`, { method: 'POST' }), t('toastRequeued'));
break;
case 'task-cancel':
act(() => api(`/api/tasks/${id}/transition`, {
method: 'POST', body: JSON.stringify({ status: 'cancelled' }),
}), t('toastCancelled'));
break;
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
renderGates();
+5
View File
@@ -398,6 +398,11 @@ pre.doc.empty { color: var(--faint); border-left-color: var(--line); }
border-top: 1px solid var(--line-soft);
}
/* needs_attention 卡片 */
.gate-card-attn { border-color: var(--orange-dim, #7a4a1a); }
.gate-kind-attn { background: var(--orange, #c96b00); }
pre.doc.attn-reason { border-left-color: var(--orange, #c96b00); color: var(--orange-text, #ffb870); white-space: pre-wrap; max-height: 120px; }
.reject-form { padding: 0 14px 12px; display: flex; gap: 10px; align-items: flex-start; }
.reject-form textarea {
flex: 1; min-height: 56px;