Add reliable execution reporting and coordinated dashboard views

This commit is contained in:
aiagent
2026-10-19 04:29:33 -07:00
parent 547f62fa53
commit c2dfe311f7
41 changed files with 3867 additions and 107 deletions
+107 -8
View File
@@ -1,14 +1,113 @@
import { Link } from 'react-router-dom'
import { useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PageHeading, Panel, Empty, Reports } from '@/components/shared'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet'
import { PageHeading, Panel, Empty } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { date } from '@/lib/types'
import { ApiError, errorText, messageKey, request } from '@/lib/api'
import { date, type Decision } from '@/lib/types'
const statuses = { open: '等待决定', decided: '已决定', pending_verification: '待核实' }
const priorities = { normal: '普通', high: '重要', urgent: '紧急' }
type Draft = { optionId: string | null; comment: string }
const emptyDraft: Draft = { optionId: null, comment: '' }
function Status({ decision }: { decision: Decision }) {
return <Badge variant="outline" className={decision.status === 'decided' ? 'status-success' : 'status-neutral'}>{statuses[decision.status]}</Badge>
}
export default function Decisions() {
const { state } = useWorkbench()
const pending = state.tasks.filter(t => t.decision)
return <div className="space-y-6"><PageHeading title="方案与决策" description="将待决定事项与方案记录集中管理。" action={<Button asChild><Link to="/communication">进入专门会话讨论 <ArrowRight /></Link></Button>} />
<Panel title="等待决定" description="以下内容由工作包登记;聊天中的来源字段不代表人类批准。">{pending.length ? <div className="divide-y">{pending.map(t => <article key={t.id} className="py-5 first:pt-0 last:pb-0"><h3 className="font-medium">{t.id} · {t.name}</h3><p className="mt-3 whitespace-pre-wrap text-sm leading-7">{t.decision}</p><p className="mt-3 text-xs text-muted-foreground">更新于 {date(t.updated)}</p></article>)}</div> : <Empty>当前没有登记待决策事项。</Empty>}</Panel>
<Panel title="方案与决策记录"><Reports events={state.events.filter(e => /decision|proposal|方案|决策/.test(e.kind))} /></Panel>
const { state, token, refresh } = useWorkbench()
const [params, setParams] = useSearchParams()
const [receipts, setReceipts] = useState<Record<string, Decision>>({})
const decisions = (state.decisions ?? []).map(decision => decision.status === 'open' ? receipts[decision.id] ?? decision : decision)
const selectedId = params.get('decision')
const selected = decisions.find(d => d.id === selectedId)
const [drafts, setDrafts] = useState<Record<string, Draft>>({})
const [notices, setNotices] = useState<Record<string, string>>({})
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [submitted, setSubmitted] = useState<Record<string, boolean>>({})
const inFlight = useRef(new Set<string>())
const attempts = useRef(new Map<string, { payload: string; key: string }>())
const acknowledged = useRef(new Set<string>())
const draft = selected ? drafts[selected.id] ?? emptyDraft : emptyDraft
const editable = selected?.status === 'open' && !busy[selected.id] && !submitted[selected.id]
function open(id: string | null) {
setParams(previous => { const next = new URLSearchParams(previous); if (id) next.set('decision', id); else next.delete('decision'); return next })
}
function updateDraft(value: Partial<Draft>) {
if (!selected || !editable) return
setDrafts(previous => ({ ...previous, [selected.id]: { ...(previous[selected.id] ?? emptyDraft), ...value } }))
}
async function sync(id: string) {
try { await refresh(); setNotices(previous => ({ ...previous, [id]: '' })) }
catch (error) { setNotices(previous => ({ ...previous, [id]: `状态同步未完成:${errorText(error)} 请刷新状态。` })) }
}
async function submit(decision: Decision) {
const id = decision.id
const current = drafts[id] ?? emptyDraft
if (decision.status !== 'open' || inFlight.current.has(id) || acknowledged.current.has(id)) return
if (decision.options.length && !decision.options.some(option => option.id === current.optionId)) return
const body = { option_id: current.optionId, comment: current.comment }
const payload = JSON.stringify(body)
let attempt = attempts.current.get(id)
if (!attempt || attempt.payload !== payload) { attempt = { payload, key: messageKey() }; attempts.current.set(id, attempt) }
inFlight.current.add(id)
setBusy(previous => ({ ...previous, [id]: true }))
setNotices(previous => ({ ...previous, [id]: '' }))
try {
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: attempt.key })
setReceipts(previous => ({ ...previous, [id]: result }))
acknowledged.current.add(id)
setSubmitted(previous => ({ ...previous, [id]: true }))
setNotices(previous => ({ ...previous, [id]: '' }))
await sync(id)
} catch (error) {
setNotices(previous => ({ ...previous, [id]: `${errorText(error)} 草稿已保留;重试相同内容不会重复提交。` }))
if (error instanceof ApiError && error.status === 409) await sync(id)
} finally {
inFlight.current.delete(id)
setBusy(previous => ({ ...previous, [id]: false }))
}
}
const legacy = state.tasks.filter(task => task.decision)
return <div className="space-y-6">
<PageHeading title="方案与决策" description="查看影响与方案,再明确提交你的选择。" action={<Button asChild variant="outline"><Link to="/communication">讨论方案 <ArrowRight /></Link></Button>} />
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="gap-0 p-0">
<button type="button" onClick={() => open(decision.id)} className="flex h-full w-full flex-col gap-4 rounded-xl p-6 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`查看决策:${decision.title}`}>
<div className="flex flex-wrap gap-2"><Badge variant="outline" className={decision.priority === 'urgent' ? 'status-danger' : 'status-neutral'}>{priorities[decision.priority]}</Badge><Status decision={decision} /></div>
<h2 className="font-semibold break-words">{decision.title}</h2>
<p className="whitespace-pre-wrap break-words text-sm leading-6">{decision.question}</p>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">影响:{decision.impact || '尚未补充'}</p>
<span className="mt-auto flex items-center gap-2 text-sm text-primary">查看方案与详情 <ArrowRight className="size-4" /></span>
</button>
</Card>)}</div> : <Panel title="决策展板"><Empty>当前没有结构化决策事项。可在沟通中请总控补充需要决定的问题与方案。</Empty></Panel>}
{legacy.length > 0 && <Panel title="待完善条目(只读)" description="以下是工作包原有的文字记录,尚不能在此提交选择。"><div className="space-y-4">{legacy.map(task => <article key={task.id} className="rounded-lg border p-4"><h3 className="font-medium">{task.id} · {task.name}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{task.decision}</p><p className="mt-2 text-xs text-muted-foreground">更新于 {date(task.updated)}</p><Button asChild variant="link" className="mt-2 h-auto p-0"><Link to="/communication">请总控补充结构化选项 <ArrowRight /></Link></Button></article>)}</div></Panel>}
<Sheet open={selectedId !== null} onOpenChange={value => { if (!value) open(null) }}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader className="pr-12"><SheetTitle>{selected?.title ?? '决策暂不可用'}</SheetTitle><SheetDescription>{selected?.question ?? '该条目尚未同步或已移除,请刷新状态后重试。'}</SheetDescription></SheetHeader>
{selected ? <>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 pb-4 break-words">
<div className="flex flex-wrap gap-2"><Badge variant="outline">{priorities[selected.priority]}</Badge><Status decision={selected} /></div>
<section><h3 className="font-medium">影响</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.impact || '尚未补充'}</p></section>
<section><h3 className="font-medium">背景与依据</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.details || '尚未补充'}</p></section>
{selected.recommendation && <section className="rounded-lg border bg-muted/50 p-4"><h3 className="font-medium">推荐与理由</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.recommendation}</p><p className="mt-2 text-xs text-muted-foreground">推荐不会替你选择。</p></section>}
{selected.deadline && Number.isFinite(Date.parse(selected.deadline)) && <p className="text-sm">截止时间:{date(selected.deadline)}</p>}
{selected.requires_authorization && <p className="rounded-lg border p-4 text-sm">此事项涉及权限。提交选择后仍需核实,不代表已授权。</p>}
{selected.options.length > 0 && <fieldset disabled={!editable} className="space-y-3"><legend className="mb-3 font-medium">方案与取舍{selected.status === 'open' ? '(请选择一项)' : ''}</legend>{selected.options.map(option => <label key={option.id} className={`flex gap-3 rounded-lg border p-4 ${editable ? 'cursor-pointer hover:bg-muted/50' : ''}`}><input type="radio" name={`decision-${selected.id}`} value={option.id} checked={selected.status === 'open' ? draft.optionId === option.id : selected.response?.option_id === option.id} onChange={() => updateDraft({ optionId: option.id })} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{selected.status === 'open' && <div className="space-y-2"><label htmlFor="decision-comment" className="text-sm font-medium">补充备注</label><Textarea id="decision-comment" value={draft.comment} onChange={event => updateDraft({ comment: event.target.value })} disabled={!editable} placeholder="补充你的考虑或要求" /></div>}
{selected.response && <section className="space-y-2 rounded-lg border p-4"><h3 className="font-medium">已记录的回复</h3><p className="text-sm">选择:{selected.options.find(option => option.id === selected.response?.option_id)?.label ?? selected.response.option_id ?? '仅备注'}</p><p className="whitespace-pre-wrap text-sm">{selected.response.comment || '无补充备注'}</p><p className="text-xs text-muted-foreground">{date(selected.response.created)}</p></section>}
{selected.status === 'pending_verification' && <p role="status" className="text-sm">选择已记录,等待核实;尚未授权。</p>}
{notices[selected.id] && <p role="status" className="text-sm">{notices[selected.id]}</p>}
</div>
<SheetFooter className="border-t">
{selected.status === 'open' && !submitted[selected.id] && <Button disabled={!editable || (selected.options.length > 0 && !selected.options.some(option => option.id === draft.optionId))} onClick={() => void submit(selected)}>{busy[selected.id] ? '正在提交…' : '提交选择'}</Button>}
<Button variant="outline" disabled={busy[selected.id]} onClick={() => void sync(selected.id)}>刷新状态</Button>
</SheetFooter>
</> : <SheetFooter><Button variant="outline" onClick={() => void sync(selectedId ?? '')}>刷新状态</Button></SheetFooter>}
</SheetContent>
</Sheet>
</div>
}