107 lines
15 KiB
TypeScript
107 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Link, useSearchParams } from 'react-router-dom'
|
||
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 } from '@/components/ui/sheet'
|
||
import { PageHeading, Panel, Empty } from '@/components/shared'
|
||
import DecisionChat from '@/components/decision-chat'
|
||
import VersionNotice from '@/components/version-notice'
|
||
import { useLocalDraft } from '@/lib/use-local-draft'
|
||
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 } from './decision-ui'
|
||
|
||
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 }: { id: string; initial?: Decision }) {
|
||
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])
|
||
const revision = decision?.revision ?? 1
|
||
const local = useLocalDraft<DecisionDraft>(state.instance_id && decision && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'decision', id, String(revision)] : null, {option:null,comment:'',revision,conflict:false}, (v): v is DecisionDraft => Boolean(v && typeof v === 'object' && typeof (v as DecisionDraft).comment === 'string' && Number.isInteger((v as DecisionDraft).revision)), true)
|
||
const draft = local.value
|
||
const sending = useRef(false)
|
||
function persist() { local.save(draft); redraw(n => n + 1) }
|
||
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
|
||
const editable = Boolean(decision && writable(decision) && !draft?.sending && local.ready)
|
||
async function submit() {
|
||
if (!decision || !draft || sending.current || !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() }
|
||
if (!local.save(draft)) return
|
||
sending.current = true
|
||
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(''); local.clear()
|
||
await refresh().catch(() => {}); await reload.current()
|
||
} catch (cause) {
|
||
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; persist(); setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
|
||
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
|
||
} finally { sending.current = false; 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"><VersionNotice />{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">
|
||
{local.error && <p role="status" className="text-xs text-destructive">{local.error}</p>}{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}{!writable(decision) && (draft.comment || draft.option) && <section className="rounded-lg border p-3 text-sm"><h3>保留的本地草稿(只读,未自动提交)</h3><p>版本 {draft.revision} · 选项 {draft.option || '无'}</p><p className="whitespace-pre-wrap">{draft.comment}</p></section>}<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; persist() }} 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; persist() }}>清除选项,仅填写意见</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; persist() }} />
|
||
{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); persist(); 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} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
|
||
</div></>
|
||
}
|
||
export default function Decisions() {
|
||
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
|
||
const id = params.get('decision')
|
||
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)} />}</SheetContent></Sheet>
|
||
</div>
|
||
}
|