Archived
M6(4/n): 工作台五要素齐活 —— 审批 / 消息 / 纪要 / 汇报
四个 desk-* feature 自 workbench 原型 ModuleWorkspace.tsx 平移(jc-* 类名原样),
共用件落 shared/desk/module-kit;mock 数据换成服务端投影:
· desk-approval:审批点卡到审批人工位;依据快照、驳回走向(声明式)、驳回必附意见
· desk-message:通知 + 跨任务征答**就地作答**;卡面表单由 card.ts schema 驱动,
与后端回传校验同 schema;跨人卡不占任务清单,不开放任务对话流
· desk-minute:纪要维护 + 决议登记起单;正文可续、决议只增
· desk-report:四周期草案 + 度量八指标 + 确认即提交
左轨角标常驻(待批 / 未读),责任面要一眼看见什么在等我。
修一个真 bug:process.start 从不校验流程包的 callInterface,
先插工作流再由 task.start 撞活动契约 —— 失败就留下一条永远推不动的在途单。
改为**先校验后建单**(CallInterfaceViolationError),一条工作流都不留;
路由回 400 并带上缺哪个字段。已清掉这次触发出的两条孤儿单。
判据 ⑩ 纪要一环跑通:决议 → 登记触发 → 工作流 → A1 落到 BD 冯兵,
登记人即来源已记在单上;同一决议重复登记被 412 挡下。
gates: tsc / eslint / depcruise(316 模块)/ api-report / 380 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 消息要素 —— 自 `~/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<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
|
||||
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<string | undefined>(undefined);
|
||||
const [filter, setFilter] = useState<'unread' | 'all'>('unread');
|
||||
|
||||
const q = useQuery({ queryKey: ['messages'], queryFn: () => call<ListMessagesOutput>(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 (
|
||||
<div className="workspace">
|
||||
<aside className="task-sidebar">
|
||||
<SidebarHead icon="messages-square" title="消息" sub={`与我相关 · ${unread.length} 条未读`} />
|
||||
<div className="jc-flt-wrap" aria-label="消息筛选">
|
||||
<div className="jc-flt" role="tablist" aria-label="按状态">
|
||||
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
|
||||
<span>未读</span>
|
||||
<strong>{unread.length}</strong>
|
||||
</button>
|
||||
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||
<span>全部</span>
|
||||
<strong>{messages.length}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.map((m) => {
|
||||
const meta = metaOf(m.kind);
|
||||
const pending = m.card !== undefined && !m.card.answered;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className={'jc-mcard' + (m.id === sel?.id ? ' selected' : '')}
|
||||
onClick={() => pick(m)}
|
||||
>
|
||||
<span className="jc-mcard-ico" style={{ background: `linear-gradient(140deg, ${meta.grad[0]}, ${meta.grad[1]})` }}>
|
||||
<Ico name={meta.icon} s={15} />
|
||||
</span>
|
||||
<span className="jc-mcard-body">
|
||||
<span className="jc-mcard-top">
|
||||
<em>{meta.kicker}</em>
|
||||
{pending ? (
|
||||
<Chip tone="todo" label="待作答" pulse />
|
||||
) : (
|
||||
<Chip tone={m.read ? 'done' : 'brand'} label={m.read ? '已读' : '未读'} pulse={!m.read} />
|
||||
)}
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-clamp2">{m.title}</strong>
|
||||
<span className="jc-mcard-foot">
|
||||
<time className="jc-mono">{m.at.slice(5, 16).replace('T', ' ')}</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && <ListEmpty icon="circle-check">没有{filter === 'unread' ? '未读' : ''}消息</ListEmpty>}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="messages-square">选一条消息</ListEmpty>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
<Hero
|
||||
grad={metaOf(sel.kind).grad}
|
||||
icon={metaOf(sel.kind).icon}
|
||||
kicker={metaOf(sel.kind).kicker}
|
||||
title={sel.title}
|
||||
meta={<time className="jc-mono">{sel.at.slice(0, 16).replace('T', ' ')}</time>}
|
||||
chip={<Chip tone={sel.read ? 'done' : 'brand'} label={sel.read ? '已读' : '未读'} />}
|
||||
/>
|
||||
|
||||
<Section icon="file-text" title="正文">
|
||||
<div className="jc-summary">{sel.body}</div>
|
||||
</Section>
|
||||
|
||||
{sel.card !== undefined && (
|
||||
<Section icon="messages-square" title="就地作答" extra={`来源任务 ${sel.card.sourceTaskId.slice(0, 8)}`}>
|
||||
{sel.card.rejectedReason !== undefined && (
|
||||
<NoteBand>被驳回重答:{sel.card.rejectedReason}(原卡已闭合,这是新的一条)</NoteBand>
|
||||
)}
|
||||
{sel.card.answered ? (
|
||||
<div className="jc-mod-donehint">
|
||||
<Ico name="circle-check" s={15} />
|
||||
已作答 · 回传已写回来源任务并留痕
|
||||
</div>
|
||||
) : (
|
||||
<ElicitationForm
|
||||
ask={sel.card.ask}
|
||||
fields={sel.card.fields}
|
||||
onAnswer={(answer) =>
|
||||
call(answerMessageRoute, { params: { messageId: sel.id }, body: { answer } }).then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['messages'] });
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NoteBand>
|
||||
跨人卡是<b>一次性的解答请求,不是责任转移</b>:作答权限只及卡面与决策包,
|
||||
不开放任务对话流;它不占你的任务清单。
|
||||
</NoteBand>
|
||||
</Section>
|
||||
)}
|
||||
</DocPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡面表单:字段由 `@contracts/card.ts` 的 schema 驱动渲染,与后端回传校验同 schema(§9.2)。
|
||||
*/
|
||||
function ElicitationForm({
|
||||
ask,
|
||||
fields,
|
||||
onAnswer,
|
||||
}: {
|
||||
ask: string;
|
||||
fields: readonly FormField[];
|
||||
onAnswer: (answer: Record<string, unknown>) => Promise<unknown>;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 必填未回填不得提交 —— 与后端回传校验同一条规矩
|
||||
const missing = fields.filter((f) => f.required === true && (values[f.key] ?? '').trim() === '');
|
||||
|
||||
return (
|
||||
<>
|
||||
{ask !== '' && <p className="jc-ask">{ask}</p>}
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="jc-field">
|
||||
<em>
|
||||
{f.label}
|
||||
{f.required === true && <i className="jc-req">必填</i>}
|
||||
</em>
|
||||
{f.kind === 'textarea' ? (
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={3}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="jc-input"
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={busy || missing.length > 0}
|
||||
title={missing.length > 0 ? `还缺:${missing.map((f) => f.label).join('、')}` : undefined}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
onAnswer(values)
|
||||
.catch((e: unknown) => setError((e as { message?: string }).message ?? '回传失败'))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
提交回传
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user