/** * 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。 * * 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报)→ 轨底身份菜单; * 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。 * * **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。 */ import { useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts'; import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, meRoute, replyWorkOrderRoute, taskDetailRoute, } from '@contracts'; import { TaskChat, TaskList, ViewRegion } from '@features/desk-task'; import type { TaskFilter } from '@features/desk-task'; import { ApprovalDesk } from '@features/desk-approval'; import { MessageDesk } from '@features/desk-message'; import { MinuteDesk } from '@features/desk-minute'; import { ReportDesk } from '@features/desk-report'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; import { logout as kcLogout } from '@web-shared/auth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { queryKeys } from './query.ts'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string }> = [ { key: 'tasks', label: '任务', icon: 'list-checks' }, { key: 'approvals', label: '审批', icon: 'file-check' }, { key: 'messages', label: '消息', icon: 'messages-square' }, { key: 'minutes', label: '纪要', icon: 'notebook-pen' }, { key: 'report', label: '汇报', icon: 'bar-chart' }, ]; export function AppShell() { const queryClient = useQueryClient(); const [module, setModule] = useState('tasks'); /** 清单状态页签(进行中/已完成)—— TaskList 上报,中间区与对话区跟着它走(2026-08-25 用户定) */ const [listFilter, setListFilter] = useState('active'); /** 「建设中」弹窗:点未开放模块(审批/消息/纪要/汇报)或个人中心时弹出,不跳页(2026-08-25 用户定) */ const [wip, setWip] = useState(null); const [taskId, setTaskId] = useState(''); const [collapsed, setCollapsed] = useState(false); /* * 工单填报值 —— **壳层持有**,工作区与对话区共用同一份。 * * 验收工单是「AI 已填好、可编辑」,而**裁决在对话区的验收单上**(PRD 口径): * 编辑与裁决是同一件事的两半,却分在两个区里做。缓冲只活在工作区里, * 人在报告上改的值就到不了裁决那一刻 —— 验收回传的 `values` 恒空,修订永远落不下来。 * * 换任务即清空:填报值是**过程状态**,不跨单。 */ const [orderValues, setOrderValues] = useState>({}); /* * 对话区点「打开工单」→ 视图区跳转定位(PRD:locate)。 * `seq` 每点递增:同一张卡点两次也要再跳一次 —— 人就是想回到它。 */ const [locate, setLocate] = useState<{ seq: number; orderId?: string; tab?: 'comms' } | null>(null); const locateSeq = useRef(0); const split = useChatWidth(); const me$ = useQuery({ queryKey: queryKeys.me, queryFn: () => call(meRoute), }); const tasks = useQuery({ queryKey: queryKeys.tasks, queryFn: () => call<{ tasks: TaskCard[] }>(listTasksRoute), }); // 角标常驻:责任面要一眼看见「什么在等我」,不能等切过去才知道 const messages = useQuery({ queryKey: queryKeys.messages, queryFn: () => call<{ messages: Array<{ id: string; read: boolean }> }>(listMessagesRoute), }); const approvals = useQuery({ queryKey: queryKeys.approvals, queryFn: () => call<{ approvals: Array<{ state: string }> }>(listApprovalsForMeRoute), }); const list = tasks.data?.tasks ?? []; const active = list.filter((t) => t.terminal === undefined); /* * 中间区/对话区跟着清单页签走:当前选中落在本页签内才保留, * 否则落到本页签第一单;页签内一单都没有就是空态 —— 不能隔着页签 * 还摆着另一页签的单(2026-08-25 用户报:切「进行中」仍显示已完成任务)。 */ const tabTasks = list.filter((t) => (listFilter === 'active' ? t.terminal === undefined : t.terminal !== undefined)); const current = taskId !== '' && tabTasks.some((t) => t.id === taskId) ? taskId : (tabTasks[0]?.id ?? ''); const detail = useQuery({ queryKey: queryKeys.task(current), queryFn: () => call(taskDetailRoute, { params: { taskId: current } }), enabled: current !== '', }); /* * 详情在拉取途中(远程库往返要几百毫秒)—— 这一刻三区不能摆空态, * 否则人刚点完页签就看到「手上没有任务」,以为这一下没点上(2026-08-25 用户报)。 */ const detailPending = current !== '' && detail.isPending; /** * 登出(ADR-012):先整体清空缓存(上一个人的任务、审批、消息不能留给下一个人), * 再走 Keycloak end-session 终结 SSO 会话跳回首页 —— 只清本地会「登不出去」(手册 §11)。 * 后端 `/api/logout` 已退役(410),这里不再调它。 */ const logout = () => { queryClient.clear(); void kcLogout(); }; const me = me$.data?.staff.name ?? '—'; /** * 工单回传 —— 视图区与对话区同一条口。 * 回传后按键失效、画面由服务端重投影:前端不自行推演卡与工单的状态(§9.2)。 */ const replyOrder = useMutation({ mutationFn: (reply: WorkOrderReply) => call(replyWorkOrderRoute, { body: { reply } }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['desk'] }); }, }); return (
{module === 'tasks' ? (
{collapsed && ( )} { // 换任务即清空填报缓冲与定位:它们是**过程状态**,不跨单 setOrderValues({}); setLocate(null); setTaskId(id); }} onCollapse={() => setCollapsed(true)} onFilterChange={(s) => { // 换页签=换一批单:填报缓冲与定位是**过程状态**,不跨单(与 onPick 同一口径) setOrderValues({}); setLocate(null); setListFilter(s); }} /> {/* 手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。 少一栏会让整个工位看着像坏了 —— 空态是常态,不是缺页。 */} {detail.data !== undefined ? ( replyOrder.mutate(reply)} values={orderValues} onField={(k, v) => setOrderValues((old) => ({ ...old, [k]: v }))} locate={locate} /> ) : ( /* * **手上没任务时,中栏也得是一张卡**。 * * 其余四个模块(审批 / 消息 / 纪要 / 汇报)的空态都挂 `jc-panel-card`, * 只有任务这一栏是裸的 `execution-panel` —— 而那个类在工作区里被显式 * 去边框去底色(视图区不再有外层卡)。于是同一套工作台上,四个模块的空态 * 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。 */
{tasks.isLoading || detailPending ? (
载入中…
) : ( )}
)}
{detail.data !== undefined ? ( { locateSeq.current += 1; setLocate({ seq: locateSeq.current, orderId }); }} onOpenComms={() => { locateSeq.current += 1; setLocate({ seq: locateSeq.current, tab: 'comms' }); }} orderValues={orderValues} onField={(k, v) => setOrderValues((old) => ({ ...old, [k]: v }))} /> ) : (
任务助理 Agent 开一单即可对话
{detailPending ? (
载入中…
) : ( )}