/** * 消息要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx` * 的 `MessagesModule` / `MessageDetail` 平移;mock 数据换成服务端投影。 * * 「消息是**信息**,不产生责任」:系统通知 + 他人任务发来的人类工具卡。 * 跨任务卡**就地作答** —— 作答权限只及卡面与决策包,**不开放任务对话流**; * 任务要素永远只装「我负责的任务」,跨人卡是一次性的解答请求,不是责任转移。 */ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { FormField, ListMessagesOutput, SystemMessageView } from '@contracts'; import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx'; import { call } from '@web-shared/typed-fetch.ts'; import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx'; const KIND_META: Readonly> = { notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' }, elicitation: { icon: 'messages-square', grad: ['#3370ff', '#245bdb'], kicker: '跨任务征答' }, 'dead-letter': { icon: 'alert-triangle', grad: ['#ff8800', '#de7802'], kicker: '死信回执' }, unowned: { icon: 'help-circle', grad: ['#f14ba9', '#c92d86'], kicker: '无主入站 · 待认领' }, }; const metaOf = (kind: string) => KIND_META[kind] ?? KIND_META['notice']!; export function MessageDesk() { const queryClient = useQueryClient(); const [selId, setSelId] = useState(undefined); const [filter, setFilter] = useState<'unread' | 'all'>('unread'); const q = useQuery({ queryKey: ['messages'], queryFn: () => call(listMessagesRoute) }); const messages = q.data?.messages ?? []; const unread = messages.filter((m) => !m.read); const visible = filter === 'unread' ? unread : messages; const sel = messages.find((m) => m.id === selId) ?? visible[0]; const markRead = useMutation({ mutationFn: (messageId: string) => call(markMessageReadRoute, { params: { messageId } }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['messages'] }), }); const pick = (m: SystemMessageView) => { setSelId(m.id); if (!m.read) markRead.mutate(m.id); }; return (
{sel === undefined ? (
选一条消息
) : ( {sel.at.slice(0, 16).replace('T', ' ')}} chip={} />
{sel.body}
{sel.card !== undefined && (
{sel.card.rejectedReason !== undefined && ( 被驳回重答:{sel.card.rejectedReason}(原卡已闭合,这是新的一条) )} {sel.card.answered ? (
已作答 · 回传已写回来源任务并留痕
) : ( call(answerMessageRoute, { params: { messageId: sel.id }, body: { answer } }).then(() => { void queryClient.invalidateQueries({ queryKey: ['messages'] }); }) } /> )} 跨人卡是一次性的解答请求,不是责任转移:作答权限只及卡面与决策包, 不开放任务对话流;它不占你的任务清单。
)}
)}
); } /** * 卡面表单:字段由 `@contracts/card.ts` 的 schema 驱动渲染,与后端回传校验同 schema(§9.2)。 */ function ElicitationForm({ ask, fields, onAnswer, }: { ask: string; fields: readonly FormField[]; onAnswer: (answer: Record) => Promise; }) { const [values, setValues] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(undefined); // 必填未回填不得提交 —— 与后端回传校验同一条规矩 const missing = fields.filter((f) => f.required === true && (values[f.key] ?? '').trim() === ''); return ( <> {ask !== '' &&

{ask}

} {fields.map((f) => (
{f.label} {f.required === true && 必填} {f.kind === 'textarea' ? (