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:
2026-07-27 03:53:36 +08:00
co-authored by Claude Opus 5
parent a60acb2e02
commit 3b9587563a
25 changed files with 1680 additions and 49 deletions
@@ -0,0 +1,5 @@
/**
* feature `desk-approval` 唯一出口(编码规范 §9.1)。
* 审批点收件与裁决:通过 / 驳回,附依据;驳回走向声明式。
*/
export { ApprovalDesk } from './internal/ApprovalDesk.tsx';
@@ -0,0 +1,186 @@
/**
* 审批要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
* 的 `ApprovalsModule` / `ApprovalDetail` 平移;mock 数据换成服务端投影。
*
* 「工作台层面的『审批』**专指工作流级的审批点**」:它卡到审批人工位,产出审批单——
* 通过 / 驳回,附依据。**没有预算、没有 Harness、没有任务日志,所以不是任务。**
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ApprovalItem, ListApprovalsForMeOutput } from '@contracts';
import { decideApprovalRoute, listApprovalsForMeRoute } 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 STATE_CHIP = {
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
passed: { tone: 'ok' as const, label: '已通过', pulse: false },
rejected: { tone: 'warn' as const, label: '已退回', pulse: false },
};
export function ApprovalDesk() {
const queryClient = useQueryClient();
const [selId, setSelId] = useState<string | undefined>(undefined);
const [filter, setFilter] = useState<'todo' | 'done'>('todo');
const [comment, setComment] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const q = useQuery({
queryKey: ['approvals'],
queryFn: () => call<ListApprovalsForMeOutput>(listApprovalsForMeRoute),
});
const approvals = q.data?.approvals ?? [];
const todo = approvals.filter((a) => a.state === 'pending');
const done = approvals.filter((a) => a.state !== 'pending');
const visible = filter === 'todo' ? todo : done;
const sel = approvals.find((a) => a.id === selId) ?? visible[0];
const decide = useMutation({
mutationFn: (input: { item: ApprovalItem; verdict: 'pass' | 'reject' }) =>
call(decideApprovalRoute, {
params: { workflowId: input.item.workflowId, nodeId: input.item.nodeId },
body: { verdict: input.verdict, ...(comment.trim() === '' ? {} : { comment: comment.trim() }) },
}),
onSuccess: () => {
setComment('');
setError(undefined);
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (e: unknown) => setError((e as { message?: string }).message ?? '裁决失败'),
});
return (
<div className="workspace">
<aside className="task-sidebar">
<SidebarHead icon="file-check" title="审批中心" sub={`等我放行的事项 · ${todo.length} 件待批`} />
<div className="jc-flt-wrap" aria-label="审批筛选">
<div className="jc-flt" role="tablist" aria-label="按状态">
<button className={filter === 'todo' ? 'active' : ''} onClick={() => setFilter('todo')}>
<span>待我审批</span>
<strong>{todo.length}</strong>
</button>
<button className={filter === 'done' ? 'active' : ''} onClick={() => setFilter('done')}>
<span>已处理</span>
<strong>{done.length}</strong>
</button>
</div>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{visible.map((a) => {
const chip = STATE_CHIP[a.state];
return (
<button
key={a.id}
className={'jc-mcard jc-acard' + (a.id === sel?.id ? ' selected' : '')}
onClick={() => setSelId(a.id)}
>
<span className="jc-mcard-ico" style={{ background: 'linear-gradient(140deg, #3370ff, #245bdb)' }}>
<Ico name="shield-check" s={15} />
</span>
<span className="jc-mcard-body">
<span className="jc-mcard-top">
<em>审批点签批</em>
<Chip tone={chip.tone} label={chip.label} pulse={chip.pulse} />
</span>
<strong className="jc-mcard-title jc-clamp2">{a.title}</strong>
<span className="jc-acard-from">
<Ico name="workflow" s={12} />
<b>{a.workflowSubject}</b>
<time className="jc-mono">{a.raisedAt.slice(5, 16).replace('T', ' ')}</time>
</span>
</span>
</button>
);
})}
{visible.length === 0 && (
<ListEmpty icon="circle-check">
{filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'}
</ListEmpty>
)}
</ScrollArea>
</aside>
{sel === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="shield-check">没有卡在你工位上的审批点</ListEmpty>
</main>
) : (
<DocPanel scrollKey={sel.id}>
<Hero
grad={['#3370ff', '#245bdb']}
icon="shield-check"
kicker={`审批点 · ${sel.nodeId}`}
title={sel.title}
meta={
<>
<Ico name="workflow" s={14} />
<b>{sel.workflowSubject}</b>
<span className="jc-dim">工作流 {sel.workflowId.slice(0, 8)}</span>
</>
}
chip={<Chip tone={STATE_CHIP[sel.state].tone} label={STATE_CHIP[sel.state].label} />}
/>
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
<div className="jc-rows">
{sel.basis.map((b) => (
<div key={b.k} className="jc-row">
<em>{b.k}</em>
<b className="jc-mono">{b.v}</b>
</div>
))}
{sel.basis.length === 0 && <span className="jc-dim">上游尚无输出口载荷</span>}
</div>
</Section>
<Section icon="git-branch" title="驳回走向" extra="声明式 · 不由临场决定">
<NoteBand>{sel.rejectRoute}</NoteBand>
</Section>
{sel.state === 'pending' ? (
<Section icon="pen-line" title="签批意见" extra="驳回必附意见">
<textarea
className="jc-sup"
rows={3}
placeholder="写下裁决依据(驳回时必填)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{error !== undefined && <div className="jc-err">{error}</div>}
<div className="jc-mod-actions">
<button
type="button"
className="card-btn"
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
>
<Ico name="rotate-ccw" s={13} />
驳回
</button>
<button
type="button"
className="card-btn primary"
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
>
<Ico name="check" s={13} />
通过
</button>
</div>
</Section>
) : (
<div className="jc-mod-actions">
<span className="jc-mod-donehint">
<Ico name="circle-check" s={15} />
已裁决 · 流程按声明继续
</span>
</div>
)}
</DocPanel>
)}
</div>
);
}
@@ -0,0 +1,5 @@
/**
* feature `desk-message` 唯一出口(编码规范 §9.1)。
* 消息:通知、跨任务征答就地作答、死信回执。
*/
export { MessageDesk } from './internal/MessageDesk.tsx';
@@ -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>
</>
);
}
@@ -0,0 +1,5 @@
/**
* feature `desk-minute` 唯一出口(编码规范 §9.1)。
* 纪要:团队主动沉淀;决议经登记触发起单,登记人即来源。
*/
export { MinuteDesk } from './internal/MinuteDesk.tsx';
@@ -0,0 +1,291 @@
/**
* 纪要要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
* 的 `MinutesModule` 平移;mock 数据换成服务端投影。
*
* 「纪要是工作台五要素里**唯一的主动动作**」——团队沉淀;
* 决议命中**登记触发**即起单,「**登记人即来源**」(凡单必有来源)。
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ListMinutesOutput, MinuteView } from '@contracts';
import { appendResolutionRoute, createMinuteRoute, listMinutesRoute, registerResolutionRoute } 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 { Avatar, AvatarStack, Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
export function MinuteDesk() {
const queryClient = useQueryClient();
const [selId, setSelId] = useState<string | undefined>(undefined);
const [composing, setComposing] = useState(false);
const [draft, setDraft] = useState({ title: '', body: '', occurredOn: '', resolution: '' });
const [registerFor, setRegisterFor] = useState<string | undefined>(undefined);
const [packageId, setPackageId] = useState('unified-quote');
const [error, setError] = useState<string | undefined>(undefined);
const q = useQuery({ queryKey: ['minutes'], queryFn: () => call<ListMinutesOutput>(listMinutesRoute) });
const minutes = q.data?.minutes ?? [];
const sel: MinuteView | undefined = minutes.find((m) => m.id === selId) ?? minutes[0];
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['minutes'] });
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
};
const create = useMutation({
mutationFn: () =>
call<MinuteView>(createMinuteRoute, {
body: {
title: draft.title,
body: draft.body,
occurredOn: draft.occurredOn,
participants: [],
resolutions: draft.resolution.trim() === '' ? [] : [{ text: draft.resolution.trim() }],
},
}),
onSuccess: (m) => {
setComposing(false);
setDraft({ title: '', body: '', occurredOn: '', resolution: '' });
setSelId(m.id);
invalidate();
},
});
const append = useMutation({
mutationFn: (input: { minuteId: string; text: string }) =>
call<MinuteView>(appendResolutionRoute, { params: { minuteId: input.minuteId }, body: { text: input.text } }),
onSuccess: invalidate,
});
const register = useMutation({
mutationFn: (resolutionId: string) =>
call<{ workflowId: string }>(registerResolutionRoute, {
params: { resolutionId },
body: { processPackageId: packageId, data: {} },
}),
onSuccess: () => {
setRegisterFor(undefined);
setError(undefined);
invalidate();
},
onError: (e: unknown) => setError((e as { message?: string }).message ?? '登记起单失败'),
});
return (
<div className="workspace">
<aside className="task-sidebar">
<SidebarHead icon="notebook-pen" title="会议纪要" sub={`团队沉淀 · ${minutes.length} 场`} />
<div className="jc-flt-wrap">
<button className="card-btn primary" onClick={() => setComposing(true)}>
<Ico name="plus" s={13} />
新建纪要
</button>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{minutes.map((m) => {
const open = m.resolutions.filter((r) => r.state === 'open').length;
const [, month, day] = m.occurredOn.split('-');
return (
<button
key={m.id}
className={'jc-mcard' + (m.id === sel?.id ? ' selected' : '')}
onClick={() => setSelId(m.id)}
>
<span className="jc-mcard-datebox">
<b>{day ?? '—'}</b>
<i>{month ?? '—'} 月</i>
</span>
<span className="jc-mcard-body">
<span className="jc-mcard-top">
<em>{m.resolutions.length} 条决议</em>
<Chip
tone={open > 0 ? 'todo' : 'ok'}
label={open > 0 ? `${open} 条待登记` : '决议已登记'}
pulse={open > 0}
/>
</span>
<strong className="jc-mcard-title jc-clamp2">{m.title}</strong>
<span className="jc-mcard-foot">
<Avatar name={m.authorName} size={14} />
记录 {m.authorName}
<i>{m.participants.length} 人</i>
</span>
</span>
</button>
);
})}
{minutes.length === 0 && <ListEmpty icon="notebook-pen">还没有纪要 —— 新建一场把团队沉淀记下来</ListEmpty>}
</ScrollArea>
</aside>
{composing ? (
<DocPanel scrollKey="new">
<Hero
grad={['#7f3bf5', '#5b21cf']}
icon="notebook-pen"
kicker="纪要 · 团队沉淀"
title="新建纪要"
meta={<span className="jc-dim">纪要本身不产生责任;责任只经决议登记起单产生</span>}
/>
<Section icon="pen-line" title="纪要正文">
<input
className="jc-input"
placeholder="标题,如「7 月报价例会」"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
<input
className="jc-input"
placeholder="会议日期 YYYY-MM-DD"
value={draft.occurredOn}
onChange={(e) => setDraft({ ...draft, occurredOn: e.target.value })}
/>
<textarea
className="jc-sup"
rows={5}
placeholder="讨论内容"
value={draft.body}
onChange={(e) => setDraft({ ...draft, body: e.target.value })}
/>
</Section>
<Section icon="git-branch" title="决议(可留空)" extra="决议可经登记触发转为工作流的启动">
<textarea
className="jc-sup"
rows={2}
placeholder="一条决议"
value={draft.resolution}
onChange={(e) => setDraft({ ...draft, resolution: e.target.value })}
/>
</Section>
<div className="jc-mod-actions">
<button type="button" className="card-btn" onClick={() => setComposing(false)}>
取消
</button>
<button
type="button"
className="card-btn primary"
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === '' || create.isPending}
onClick={() => create.mutate()}
>
<Ico name="check" s={13} />
保存纪要
</button>
</div>
</DocPanel>
) : sel === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="notebook-pen">选一场纪要,或新建一场</ListEmpty>
</main>
) : (
<DocPanel scrollKey={sel.id}>
<Hero
grad={['#7f3bf5', '#5b21cf']}
icon="notebook-pen"
kicker="纪要 · 团队沉淀"
title={sel.title}
meta={
<>
<span className="jc-mono">{sel.occurredOn}</span>
<Avatar name={sel.authorName} size={20} />
<b>{sel.authorName}</b>
<span className="jc-dim">记录</span>
</>
}
/>
{sel.participants.length > 0 && (
<div className="jc-attendees">
<AvatarStack names={sel.participants} />
<span className="jc-attendees-txt">
<strong>出席 {sel.participants.length} 人</strong>
</span>
</div>
)}
<Section icon="file-text" title="纪要正文">
<div className="jc-summary">{sel.body}</div>
</Section>
<Section icon="git-branch" title="决议清单" extra="命中登记触发即起单 · 登记人即来源">
<div className="jc-res-list">
{sel.resolutions.map((r, i) => (
<div key={r.id} className="jc-res">
<span className="jc-res-n">{i + 1}</span>
<p>{r.text}</p>
{r.state === 'registered' ? (
<span className="jc-chip t-ok">
<Ico name="check" s={11} />
已起单 · {r.workflowId?.slice(0, 8)}
</span>
) : registerFor === r.id ? (
<span className="jc-res-reg">
<input
className="jc-input"
value={packageId}
onChange={(e) => setPackageId(e.target.value)}
placeholder="流程包 id"
/>
<button
type="button"
className="card-btn primary"
disabled={register.isPending}
onClick={() => register.mutate(r.id)}
>
提交起单
</button>
<button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}>
取消
</button>
</span>
) : (
<button type="button" className="card-btn primary" onClick={() => setRegisterFor(r.id)}>
<Ico name="pen-line" s={13} />
登记起单
</button>
)}
</div>
))}
{sel.resolutions.length === 0 && <span className="jc-dim">这场纪要还没有决议</span>}
</div>
{error !== undefined && <div className="jc-err">{error}</div>}
<NoteBand>
登记触发 = 人工起单的统一形态:选流程包 → 按调用接口 schema 填表(表单自动渲染)→
提交即起单;这条工作流的来源就记为登记人。
</NoteBand>
</Section>
<Section icon="plus" title="追加决议" extra="只增不改 · 纪要是沉淀,不是可覆写的文档">
<AppendResolution onAppend={(text) => append.mutate({ minuteId: sel.id, text })} busy={append.isPending} />
</Section>
</DocPanel>
)}
</div>
);
}
function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void; busy: boolean }) {
const [text, setText] = useState('');
return (
<div className="jc-res-append">
<textarea
className="jc-sup"
rows={2}
placeholder="新增一条决议"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button
type="button"
className="card-btn primary"
disabled={text.trim() === '' || busy}
onClick={() => {
onAppend(text.trim());
setText('');
}}
>
<Ico name="plus" s={13} />
追加
</button>
</div>
);
}
@@ -0,0 +1,5 @@
/**
* feature `desk-report` 唯一出口(编码规范 §9.1)。
* 汇报:日 / 周 / 月 / 年聚合;确认即提交,零手工汇报。
*/
export { ReportDesk } from './internal/ReportDesk.tsx';
@@ -0,0 +1,181 @@
/**
* 汇报要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
* 的 `ReportModule` 平移;mock 数据换成服务端投影。
*
* 「按日 / 周 / 月 / 年**自动**总结此员工的工作——由本人各任务的**任务日志**聚合生成,
* 确认即提交,**零手工汇报**。」结构化部分零 LLM(事件流骨架 + 归侧分栏)。
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReportPeriodName, ReportView } from '@contracts';
import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } 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 PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
{ k: 'day', ico: 'calendar-days' },
{ k: 'week', ico: 'calendar-clock' },
{ k: 'month', ico: 'calendar-days' },
{ k: 'year', ico: 'history' },
];
const pct = (v: number): string => `${Math.round(v * 100)}%`;
export function ReportDesk() {
const queryClient = useQueryClient();
const [period, setPeriod] = useState<ReportPeriodName>('day');
const [note, setNote] = useState('');
const q = useQuery({
queryKey: ['report', period],
queryFn: () => call<ReportView>(draftReportRoute, { params: { period } }),
});
const report = q.data;
const submitted = report?.submittedAt !== undefined;
const submit = useMutation({
mutationFn: () =>
call<ReportView>(submitReportRoute, {
params: { reportId: report!.id },
body: note.trim() === '' ? {} : { note: note.trim() },
}),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['report'] }),
});
return (
<div className="workspace">
<aside className="task-sidebar">
<SidebarHead icon="bar-chart" title="工作汇报" sub="任务日志聚合 · 零手工汇报" />
<div className="jc-flt-wrap" aria-label="汇报周期">
<div className="jc-flt" role="tablist" aria-label="按周期">
{PERIODS.map((p) => (
<button key={p.k} className={period === p.k ? 'active' : ''} onClick={() => setPeriod(p.k)}>
<span>{REPORT_PERIOD_LABEL[p.k]}</span>
</button>
))}
</div>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{report !== undefined && (
<button className="jc-mcard selected">
<span className="jc-mcard-datebox">
<b>{report.window.from.slice(8, 10)}</b>
<i>{report.window.from.slice(5, 7)} 月</i>
</span>
<span className="jc-mcard-body">
<span className="jc-mcard-top">
<em>{report.title}</em>
<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />
</span>
<strong className="jc-mcard-title jc-wrap">
经手 {report.counts.touched} 单 · 放行 {report.counts.released} 单
</strong>
<span className="jc-mcard-foot">
<Ico name="git-branch" s={12} />
{report.counts.logLines} 条动作
<i>系统生成</i>
</span>
</span>
</button>
)}
</ScrollArea>
</aside>
{report === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
</main>
) : (
<DocPanel scrollKey={`${period}:${report.id}`}>
<Hero
grad={['#0aa294', '#0a6a64']}
icon="bar-chart"
kicker={`汇报 · ${REPORT_PERIOD_LABEL[period]}`}
title={report.title}
meta={
<>
<Ico name="bot" s={14} />
<b>系统自动生成</b>
<span className="jc-dim">由本人各任务的任务日志聚合</span>
</>
}
chip={<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />}
/>
<Section icon="bar-chart" title="度量八指标(零埋点 · 口径挂在归侧标签上)" extra={`共 ${report.counts.logLines} 条日志`}>
<div className="jc-stats">
{[
{ ico: 'bot', label: '自主率', v: pct(report.metrics.autonomy) },
{ ico: 'rotate-ccw', label: '纠正率', v: pct(report.metrics.correction) },
{ ico: 'gauge', label: '有效自主率', v: pct(report.metrics.effectiveAutonomy) },
{ ico: 'hand', label: '打扰率', v: report.metrics.interruption.toFixed(1) },
{ ico: 'git-commit', label: 'DMN 覆盖率', v: pct(report.metrics.dmnCoverage) },
{ ico: 'circle-dot', label: '断点率', v: pct(report.metrics.breakpoint) },
].map((s) => (
<div key={s.label} className="jc-stat">
<span className="jc-stat-ico">
<Ico name={s.ico} s={15} />
</span>
<span className="jc-stat-v">{s.v}</span>
<span className="jc-stat-k">{s.label}</span>
</div>
))}
</div>
<NoteBand>
自主率 = 机器产出 ÷(机器产出 + 人产出),裁决与等待不进分母;它与纠正率
<b>成对读</b>,复合为有效自主率 —— 单看任一都会被优化坏。
单位成本 {Math.round(report.metrics.unitCost.tokens)} token /
{report.metrics.unitCost.minutes.toFixed(1)} 分钟每放行单,分母只认放行通过。
</NoteBand>
</Section>
<Section icon="clock" title="动作明细(结构化日志 · 零 LLM)" extra={`${report.sections.length} 单任务`}>
{report.sections.map((s) => (
<div key={s.taskId} className="jc-axis">
<div className="jc-sec-head">
<Ico name="list-checks" s={13} />
<span>{s.title}</span>
{s.terminalLabel !== undefined && <span className="jc-sec-extra">{s.terminalLabel}</span>}
</div>
{s.lines.map((line, i) => (
<div key={i} className="jc-axis-row">
<span className="jc-axis-node" />
<p>{line}</p>
</div>
))}
</div>
))}
{report.sections.length === 0 && <span className="jc-dim">这个周期内没有动作</span>}
</Section>
<Section icon="pen-line" title="员工补充" extra="可选 · 系统不要求手工写汇报">
<textarea
className="jc-sup"
rows={3}
disabled={submitted}
placeholder="补充主观判断或下阶段计划(可留空)"
value={submitted ? (report.note ?? '') : note}
onChange={(e) => setNote(e.target.value)}
/>
</Section>
<div className="jc-mod-actions">
{submitted ? (
<span className="jc-mod-donehint">
<Ico name="circle-check" s={15} />
已确认提交 · 汇报进入组织视图
</span>
) : (
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}>
<Ico name="check" s={13} />
确认提交
</button>
)}
</div>
</DocPanel>
)}
</div>
);
}