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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user