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