Add revisioned decisions and scoped consultation lifecycle

This commit is contained in:
aiagent
2026-10-19 22:28:53 -07:00
parent 0fcfe1c024
commit 6e4178dc79
27 changed files with 1431 additions and 192 deletions
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { request, errorText, messageKey } from '@/lib/api'
import type { Decision, Message } from '@/lib/types'
import type { ChatDraft } from '@/pages/decision-ui'
export default function DecisionChat({ decision, token, drafts }: { decision: Decision; token: string; drafts: Map<string, ChatDraft> }) {
const id = decision.id, bound = Boolean(decision.session_id && decision.session_thread_id)
const readOnly = decision.status === 'completed' || decision.archive_status === 'archived'
const [messages, setMessages] = useState<Message[]>([]), [error, setError] = useState(''), [loaded, setLoaded] = useState(false), [, redraw] = useState(0)
const refresh = useRef<() => Promise<void>>(async () => {})
const scroll = useRef<HTMLDivElement>(null)
if (!drafts.has(id)) drafts.set(id, { text: '', sending: false, error: '' })
const draft = drafts.get(id)!
useEffect(() => {
if (!bound) return
const controller = new AbortController(); let busy = false
const poll = async () => {
if (busy || controller.signal.aborted) return
busy = true
try { const result = await request<{ messages: Message[] }>(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, undefined, controller.signal); if (!controller.signal.aborted) { const box = scroll.current; const bottom = !box || box.scrollHeight - box.scrollTop - box.clientHeight < 120; setMessages(result.messages); setLoaded(true); setError(''); if (bottom) requestAnimationFrame(() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight }) } }
catch (cause) { if (!controller.signal.aborted) setError(errorText(cause)) }
finally { busy = false }
}
refresh.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
return () => { clearInterval(timer); controller.abort() }
}, [id, token, bound])
async function send() {
if (!bound || readOnly || draft.sending || !draft.text.trim()) return
const text = draft.text
if (!draft.attempt || draft.attempt.text !== text) draft.attempt = { text, key: messageKey() }
draft.sending = true; draft.error = ''; redraw(n => n + 1)
try { await request(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, { text, client_key: draft.attempt.key }); if (draft.text === text) draft.text = ''; draft.attempt = undefined; await refresh.current() }
catch (cause) { draft.error = `${errorText(cause)} 草稿已保留,可重试同一消息。` }
finally { draft.sending = false; redraw(n => n + 1) }
}
return <section className="flex min-w-0 flex-col rounded-xl border bg-card" aria-label="本决策咨询聊天"><div className="flex items-center justify-between border-b p-3"><h3 className="font-medium">本决策咨询</h3><Button size="sm" variant="ghost" disabled={!bound} onClick={() => void refresh.current()}>刷新</Button></div>
<p className="px-3 pt-3 text-xs text-muted-foreground">{!bound ? '咨询会话待接通,暂不能发送。' : readOnly ? '本决策已完成,咨询历史只读。' : '消息仅发送到本决策绑定的咨询会话。绑定不代表回复链路已验收。'}</p>
<div ref={scroll} role="log" aria-label="本决策消息" className="max-h-[45vh] min-h-40 space-y-3 overflow-y-auto p-3">{messages.map(message => <article key={message.id} className={`rounded-lg p-3 text-sm ${message.source === 'human' ? 'ml-6 bg-primary text-primary-foreground' : 'mr-6 bg-muted'}`}><p className="mb-1 text-xs opacity-70">{message.source === 'human' ? '你' : '咨询助手'}</p><p className="whitespace-pre-wrap break-words">{message.text}</p>{message.source === 'human' && ['queued','claimed'].includes(message.status) && <p className="mt-1 text-xs">等待回复…</p>}{['failed','uncertain'].includes(message.status) && <p className="mt-1 text-xs">回复尚未确认,请勿重复发送。</p>}</article>)}{!messages.length && <p className="text-sm text-muted-foreground">{!bound ? '暂无已接通的咨询会话。' : loaded ? '暂无咨询消息。' : '正在读取咨询历史…'}</p>}</div>
{(error || draft.error) && <p role="status" className="px-3 pb-3 text-sm text-destructive">{draft.error || error}</p>}
{!readOnly && <form className="space-y-2 border-t p-3" onSubmit={event => { event.preventDefault(); void send() }}><Textarea aria-label="本决策咨询问题" value={draft.text} disabled={!bound || draft.sending} maxLength={32000} onChange={event => { draft.text = event.target.value; redraw(n => n + 1) }} placeholder="询问方案、依据或影响…" /><Button type="submit" disabled={!bound || draft.sending || !draft.text.trim()}>{draft.sending ? '正在发送…' : '发送咨询'}</Button></form>}
</section>
}
+8 -3
View File
@@ -21,13 +21,18 @@ export function date(value?: string | number) { return value == null ? '暂无'
export function projectTitle(state: State) { return state.config?.title || state.title || 'Agent Workbench' }
export type DecisionOption = { id: string; label: string; description: string }
export type DecisionResponse = { id: string; option_id: string | null; comment: string; created: string | number; authorization: false }
export type DecisionResponse = { id: string; option_id: string | null; comment: string; created: string | number; authorization: false; revision?: number; snapshot?: { title?: string; question?: string; details?: string; impact?: string; options?: DecisionOption[] } }
export type Decision = {
id: string; title: string; question: string; impact: string
id: string; title: string; question: string; impact: string; summary?: string
priority: 'normal' | 'high' | 'urgent'
status: 'open' | 'decided' | 'pending_verification'
status: 'open' | 'pending_review' | 'accepted' | 'completed' | 'needs_reconsideration' | 'decided' | 'pending_verification'
details: string; recommendation: string; options: DecisionOption[]
deadline: string | null; requires_authorization: boolean; task_id: string | null
revision?: number; session_id?: string | null; session_thread_id?: string | null
archive_status?: 'none' | 'pending' | 'archived'
review?: { revision?: number; outcome: 'accept' | 'reconsider'; reason: string; impact: string; execution_plan: string; created?: number } | null
completion?: { revision?: number; conclusion: string; handoff: string; evidence?: string[]; created?: number } | null
supersedes_id?: string | null; related_decision_ids?: string[]
response: DecisionResponse | null
}
+2 -2
View File
@@ -53,7 +53,7 @@ export default function Communication() {
if (loaded.current !== selected || box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight
loaded.current = selected
}, [lastId, selected, messages.session])
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => d.status === 'open') || []
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => ['open','needs_reconsideration'].includes(d.status)) || []
const text = drafts[selected] || ''
async function send(event: FormEvent) {
event.preventDefault()
@@ -92,4 +92,4 @@ export default function Communication() {
</form>
</section>
</div>
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { Decision } from '@/lib/types'
export function decisionStatus(decision: Decision) {
return decision.status === 'decided' || decision.status === 'pending_verification' ? (decision.response ? 'pending_review' : 'open') : decision.status
}
export const decisionLabels = { open: '等待决定', pending_review: '等待检查', accepted: '已接受,待落实', completed: '已完成', needs_reconsideration: '需要重新考虑' }
export type DecisionDraft = { option: string | null; comment: string; revision: number; conflict: boolean; attempt?: { signature: string; key: string }; sending?: boolean }
export type ChatDraft = { text: string; sending: boolean; error: string; attempt?: { text: string; key: string } }
export function writable(decision: Decision) { return ['open', 'needs_reconsideration'].includes(decisionStatus(decision)) }
export function submissionBody(decision: Decision, draft: DecisionDraft) {
const revision = decision.revision ?? 1
if (!writable(decision) || draft.sending || draft.conflict || draft.revision !== revision) return null
if (draft.option && !decision.options.some(option => option.id === draft.option)) return null
if (!draft.option && !draft.comment.trim()) return null
return { expected_revision: revision, option_id: draft.option, comment: draft.comment }
}
export function confirmDraft(decision: Decision, draft: DecisionDraft) {
draft.revision = decision.revision ?? 1; draft.conflict = false; draft.option = null; draft.attempt = undefined
}
+86 -99
View File
@@ -1,113 +1,100 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
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 { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { PageHeading, Panel, Empty } from '@/components/shared'
import DecisionChat from '@/components/decision-chat'
import { useWorkbench } from '@/lib/context'
import { ApiError, errorText, messageKey, request } from '@/lib/api'
import { date, type Decision } from '@/lib/types'
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft, type ChatDraft } from './decision-ui'
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>
type HistoryEntry = { id?: string | number; kind?: string; created?: number; revision?: number; data?: Record<string, unknown> }
function Status({ decision }: { decision: Decision }) { const status = decisionStatus(decision); return <Badge variant="outline" className={status === 'completed' ? 'status-success' : status === 'needs_reconsideration' ? 'status-danger' : 'status-neutral'}>{decisionLabels[status]}</Badge> }
function Text({ label, text }: { label: string; text?: string }) { return text ? <section><h3 className="font-medium">{label}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{text}</p></section> : null }
const historyLabels: Record<string, string> = { version: '决策版本', submission: '提交选择', review: '主控检查', completion: '完成交接', binding: '绑定咨询会话', archive: '归档确认', linked: '关联决策', created: '建立决策', updated: '内容更新', revised: '版本更新', submitted: '提交选择', responded: '提交选择', reviewed: '检查结论', completed: '完成交接', bound: '绑定咨询会话', session_bound: '绑定咨询会话', archived: '归档确认', fact_requested: '请求核实', fact_answered: '核实回复' }
function HistoryRecord({ entry }: { entry: HistoryEntry }) {
const data = entry.data ?? {}
const snapshot = data.snapshot && typeof data.snapshot === 'object' ? data.snapshot as Record<string, unknown> : data.content && typeof data.content === 'object' ? data.content as Record<string, unknown> : data
const options = Array.isArray(snapshot.options) ? snapshot.options.filter((item): item is { id: string; label: string; description?: string } => Boolean(item && typeof item === 'object' && typeof item.id === 'string' && typeof item.label === 'string')) : []
return <li className="space-y-2 rounded-lg border p-3 text-sm"><p className="font-medium">{historyLabels[entry.kind ?? ''] || '处理记录'}{entry.revision ? ` · 版本 ${entry.revision}` : ''} · {date(entry.created)}</p>
{typeof data.option_id === 'string' && <p>当时选择:{options.find(option => option.id === data.option_id)?.label || data.option_id}</p>}
{Object.entries({ reason:'理由', comment:'提交意见', conclusion:'完成结论', handoff:'交接', impact:'影响', execution_plan:'执行计划', question:'问题', answer:'核实回复' }).map(([key,label]) => typeof data[key] === 'string' ? <Text key={key} label={label} text={data[key] as string} /> : null)}
{snapshot !== data && typeof snapshot.question === 'string' && <Text label="当时的问题" text={snapshot.question} />}
{options.length > 0 && <details><summary className="cursor-pointer">查看当时全部方案</summary><ul className="mt-2 space-y-2">{options.map(option => <li key={option.id}><p className="font-medium">{option.label}</p><p className="whitespace-pre-wrap">{option.description}</p></li>)}</ul></details>}
</li>
}
function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision; drafts: Map<string, DecisionDraft>; chats: Map<string, ChatDraft> }) {
const { token, refresh, state } = useWorkbench()
const [decision, setDecision] = useState(initial), [history, setHistory] = useState<HistoryEntry[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [, redraw] = useState(0)
const reload = useRef<() => Promise<void>>(async () => {}), generation = useRef(0)
useEffect(() => {
const controller = new AbortController(); let pending = false
const poll = async () => {
if (pending || controller.signal.aborted) return
pending = true; const seq = ++generation.current
try { const current = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}`, undefined, controller.signal); if (!controller.signal.aborted && seq === generation.current) { setDecision(current); setError('') }
const events = await request<{ history: HistoryEntry[] }>(token, `/api/decisions/${encodeURIComponent(id)}/history`, undefined, controller.signal); if (!controller.signal.aborted && seq === generation.current) setHistory(events.history)
} catch (cause) { if (!controller.signal.aborted && seq === generation.current) setError(errorText(cause)) } finally { pending = false }
}
reload.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
return () => { controller.abort(); clearInterval(timer) }
}, [id, token])
if (decision && !drafts.has(id)) drafts.set(id, { option: null, comment: '', revision: decision.revision ?? 1, conflict: false })
const draft = drafts.get(id)
const revision = decision?.revision ?? 1
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
const editable = Boolean(decision && writable(decision) && !draft?.sending)
async function submit() {
if (!decision || !draft || !editable || changed || (!draft.option && !draft.comment.trim())) return
const body = submissionBody(decision, draft)
if (!body) return
const signature = JSON.stringify(body)
if (!draft.attempt || draft.attempt.signature !== signature) draft.attempt = { signature, key: messageKey() }
draft.sending = true; redraw(n => n + 1); setNotice('')
try {
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: draft.attempt.key })
++generation.current; setDecision(result); setNotice(''); draft.option = null; draft.comment = ''; draft.attempt = undefined
await refresh().catch(() => {}); await reload.current()
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
} finally { draft.sending = false; redraw(n => n + 1) }
}
return <><SheetHeader className="pr-12"><SheetTitle>{decision?.title ?? '读取决策…'}</SheetTitle><SheetDescription>{decision?.summary || decision?.question || '查看方案、检查结论与本决策咨询。'}</SheetDescription></SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5">{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
{decision && draft ? <div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"><div className="min-w-0 space-y-5">
<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
<Text label="需要决定的问题" text={decision.question} /><Text label="背景与依据" text={decision.details} /><Text label="影响" text={decision.impact} /><Text label="推荐与理由(不会替你选择)" text={decision.recommendation} />
{decision.deadline && Number.isFinite(Date.parse(decision.deadline)) && <p className="text-sm">截止时间:{date(decision.deadline)}</p>}
<p className="text-xs text-muted-foreground">选择提交后需检查;接受方案不代表权限授权,也不代表已经完成。</p>
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; redraw(n => n + 1) }} 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 text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; redraw(n => n + 1) }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; redraw(n => n + 1) }} />
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); setNotice('已确认当前版本,请重新选择方案或检查保留的意见后提交。'); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
<Button disabled={!editable || changed || (!draft.option && !draft.comment.trim())} onClick={() => void submit()}>{draft.sending ? '正在提交…' : '提交本次选择或意见'}</Button>
</div>}
{notice && <p role="status" className="text-sm">{notice}</p>}
{decision.response && <section className="space-y-2 rounded-lg border p-3"><h3 className="font-medium">最近提交</h3><p className="text-sm">提交版本:{decision.response.revision ?? '旧记录'} · {date(decision.response.created)}</p><p className="text-sm">选项:{decision.response.snapshot?.options?.find(option => option.id === decision.response?.option_id)?.label ?? decision.response.option_id ?? '仅填写意见'}</p><p className="whitespace-pre-wrap text-sm">{decision.response.comment}</p></section>}
{decision.review && <section className="space-y-3 rounded-lg border p-3"><h3 className="font-medium">检查结论:{decision.review.outcome === 'accept' ? '接受,待落实' : '需要重新考虑'}</h3><Text label="理由" text={decision.review.reason} /><Text label="影响" text={decision.review.impact} /><Text label="执行计划" text={decision.review.execution_plan} /></section>}
{decision.completion && <section className="space-y-3 rounded-lg border p-3"><Text label="完成结论" text={decision.completion.conclusion} /><Text label="交接说明" text={decision.completion.handoff} />{decision.completion.evidence?.map((text,index) => <p key={index} className="break-words text-sm">{text}</p>)}</section>}
{decision.status === 'completed' && <p className="text-sm">{decision.archive_status === 'archived' ? '咨询会话已确认归档,历史只读。' : '决策已完成,咨询会话待归档确认。'}</p>}
{Boolean(decision.supersedes_id || decision.related_decision_ids?.length) && <section><h3 className="mb-2 font-medium">关联决策</h3>{[...new Set([...(decision.supersedes_id ? [decision.supersedes_id] : []), ...(decision.related_decision_ids ?? [])])].map(other => <Link key={other} className="mr-3 text-sm text-primary underline" to={`/decisions?decision=${encodeURIComponent(other)}`}>{other}{other === decision.supersedes_id ? '(被此决策替代)' : ''}</Link>)}</section>}
<section><h3 className="mb-2 font-medium">版本与处理历史</h3>{history.length ? <ol className="space-y-3">{history.map((entry,index) => <HistoryRecord key={entry.id ?? index} entry={entry} />)}</ol> : <p className="text-sm text-muted-foreground">暂无已读取的历史记录。</p>}</section>
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} drafts={chats} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
</div></>
}
export default function Decisions() {
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>
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
const id = params.get('decision'), drafts = useRef(new Map<string, DecisionDraft>()), chats = useRef(new Map<string, ChatDraft>())
const decisions = state.decisions ?? []
return <div className="space-y-6"><PageHeading title="方案与决策" description="查看方案、咨询依据并明确提交,跟踪检查与落实结果。" />
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="p-0"><button type="button" onClick={() => setParams(previous => { const next = new URLSearchParams(previous); next.set('decision',decision.id); return next })} className="flex h-full w-full flex-col gap-3 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"><div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">{decision.task_id ? state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id : '未关联任务'}</Badge></div><h2 className="font-semibold">{decision.title}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{decision.summary || decision.question}</p><span className="mt-auto text-sm text-primary">查看方案与咨询</span></button></Card>)}</div> : <Panel title="决策展板"><Empty>当前没有登记决策事项。</Empty></Panel>}
{state.tasks.some(task => task.decision) && <Panel title="旧版文字条目(只读)">{state.tasks.filter(task => task.decision).map(task => <p key={task.id} className="mb-3 whitespace-pre-wrap text-sm">{task.name}:{task.decision}</p>)}</Panel>}
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} drafts={drafts.current} chats={chats.current} />}</SheetContent></Sheet>
</div>
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { useWorkbench } from '@/lib/context'
import { phaseLabels } from '@/lib/types'
export default function Overview() {
const { state } = useWorkbench()
const pending = [...state.tasks.filter(t => t.decision), ...((state as { decisions?: { id: string; title: string; question: string; status: string }[] }).decisions || []).filter(d => d.status === 'open').map(d => ({ id: d.id, name: d.title, decision: d.question }))]
const pending = [...state.tasks.filter(t => t.decision), ...((state as { decisions?: { id: string; title: string; question: string; status: string }[] }).decisions || []).filter(d => ['open','needs_reconsideration'].includes(d.status)).map(d => ({ id: d.id, name: d.title, decision: d.question }))]
const blocked = state.tasks.filter(t => ['blocked', 'failed'].includes(t.status))
const lead = state.tasks.find(t => t.id === 'WP0')
const stats = [{ label: '工作包', value: state.tasks.length, icon: Layers3 }, { label: '进行中', value: state.tasks.filter(t => t.status === 'running').length, icon: Activity }, { label: '已完成', value: state.tasks.filter(t => t.status === 'completed').length, icon: CheckCircle2 }, { label: '待决定', value: pending.length, icon: CircleHelp }]
@@ -0,0 +1,9 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { decisionStatus, submissionBody, confirmDraft } from '../src/pages/decision-ui.ts'
const decision={status:'open',revision:2,options:[{id:'a'}]}
const draft=()=>({option:null,comment:'',revision:2,conflict:false})
test('free text allowed with enumerated options; empty and invalid options rejected',()=>{assert.equal(submissionBody(decision,draft()),null);assert.equal(submissionBody(decision,{...draft(),comment:' '}),null);assert.deepEqual(submissionBody(decision,{...draft(),comment:'其他意见'}),{expected_revision:2,option_id:null,comment:'其他意见'});assert.equal(submissionBody(decision,{...draft(),option:'gone'}),null)})
test('old revision or conflict cannot resubmit without explicit confirmation',()=>{const d={...draft(),revision:1,option:'a',comment:'保留意见',conflict:true,attempt:{key:'old',signature:'old'}};assert.equal(submissionBody(decision,d),null);confirmDraft(decision,d);assert.equal(d.option,null);assert.equal(d.comment,'保留意见');assert.equal(d.attempt,undefined);assert.equal(submissionBody(decision,d).expected_revision,2)})
test('pending accepted completed read only; reconsideration permits new submission',()=>{for(const status of ['pending_review','accepted','completed'])assert.equal(submissionBody({...decision,status},{...draft(),option:'a'}),null);assert.ok(submissionBody({...decision,status:'needs_reconsideration'},{...draft(),option:'a'}));assert.equal(submissionBody(decision,{...draft(),option:'a',sending:true}),null)})
test('legacy response maps to pending review, never acceptance',()=>{for(const status of ['decided','pending_verification'])assert.equal(decisionStatus({status,response:{id:'r'}}),'pending_review')})