Archived
356 lines
15 KiB
TypeScript
356 lines
15 KiB
TypeScript
/**
|
|
* 工作台外壳 —— 自原型 `(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<ModuleKey>('tasks');
|
|
/** 清单状态页签(进行中/已完成)—— TaskList 上报,中间区与对话区跟着它走(2026-08-25 用户定) */
|
|
const [listFilter, setListFilter] = useState<TaskFilter>('active');
|
|
/** 「建设中」弹窗:点未开放模块(审批/消息/纪要/汇报)或个人中心时弹出,不跳页(2026-08-25 用户定) */
|
|
const [wip, setWip] = useState<string | null>(null);
|
|
const [taskId, setTaskId] = useState('');
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
/*
|
|
* 工单填报值 —— **壳层持有**,工作区与对话区共用同一份。
|
|
*
|
|
* 验收工单是「AI 已填好、可编辑」,而**裁决在对话区的验收单上**(PRD 口径):
|
|
* 编辑与裁决是同一件事的两半,却分在两个区里做。缓冲只活在工作区里,
|
|
* 人在报告上改的值就到不了裁决那一刻 —— 验收回传的 `values` 恒空,修订永远落不下来。
|
|
*
|
|
* 换任务即清空:填报值是**过程状态**,不跨单。
|
|
*/
|
|
const [orderValues, setOrderValues] = useState<Record<string, string>>({});
|
|
/*
|
|
* 对话区点「打开工单」→ 视图区跳转定位(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<Principal>(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<TaskDetail>(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 (
|
|
<div className="app-shell">
|
|
<div className="app-body">
|
|
<nav className="app-nav">
|
|
<div className="app-nav-mark" title="JCAI 智能协同工作台">
|
|
<Ico name="bot" s={20} />
|
|
</div>
|
|
<span className="jc-rail-brand">JCAI</span>
|
|
{NAV_MODULES.map((m) => {
|
|
const badge =
|
|
m.key === 'tasks'
|
|
? active.length
|
|
: m.key === 'approvals'
|
|
? (approvals.data?.approvals ?? []).filter((a) => a.state === 'pending').length
|
|
: m.key === 'messages'
|
|
? (messages.data?.messages ?? []).filter((x) => !x.read).length
|
|
: undefined;
|
|
return (
|
|
<button
|
|
key={m.key}
|
|
className={module === m.key ? 'active' : ''}
|
|
onClick={() => (m.key === 'tasks' ? setModule('tasks') : setWip(m.label))}
|
|
aria-current={module === m.key ? 'page' : undefined}
|
|
>
|
|
<span className="app-nav-icon-wrap">
|
|
<Ico name={m.icon} s={20} />
|
|
{badge !== undefined && badge > 0 ? <span className="app-nav-badge">{badge}</span> : null}
|
|
</span>
|
|
<span>{m.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{/* 身份菜单收在轨底(2026-08-26 用户定:顶栏撤掉,内容挪到左轨底部)——
|
|
轨宽 78px,头像上姓名下竖排;悬停/聚焦从轨右侧弹出,登出仍是全站唯一入口 */}
|
|
<div className="jc-usermenu jc-usermenu-rail">
|
|
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
|
<span className="jc-topbar-name">{me}</span>
|
|
<div className="jc-usermenu-pop">
|
|
{/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */}
|
|
<a href="/me">
|
|
<Ico name="user" s={14} />
|
|
个人中心
|
|
</a>
|
|
{/* 运营中心直链活动板块(2026-08-28 用户定,原设计中心入口改此),整页导航过去 */}
|
|
<a href="/ops#/ops/tasks/active">
|
|
<Ico name="list-checks" s={14} />
|
|
运营中心
|
|
</a>
|
|
<button type="button" className="danger" onClick={logout}>
|
|
<Ico name="log-out" s={14} />
|
|
退出登录
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="app-main">
|
|
<div className="app-main-body">
|
|
{module === 'tasks' ? (
|
|
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
|
|
{collapsed && (
|
|
<button
|
|
type="button"
|
|
className="jc-wl-expand"
|
|
title="展开列表区"
|
|
aria-label="展开列表区"
|
|
onClick={() => setCollapsed(false)}
|
|
>
|
|
<Ico name="chevron-right" s={12} />
|
|
</button>
|
|
)}
|
|
<TaskList
|
|
tasks={list}
|
|
selectedId={current}
|
|
collapsed={collapsed}
|
|
onPick={(id) => {
|
|
// 换任务即清空填报缓冲与定位:它们是**过程状态**,不跨单
|
|
setOrderValues({});
|
|
setLocate(null);
|
|
setTaskId(id);
|
|
}}
|
|
onCollapse={() => setCollapsed(true)}
|
|
onFilterChange={(s) => {
|
|
// 换页签=换一批单:填报缓冲与定位是**过程状态**,不跨单(与 onPick 同一口径)
|
|
setOrderValues({});
|
|
setLocate(null);
|
|
setListFilter(s);
|
|
}}
|
|
/>
|
|
{/*
|
|
手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。
|
|
少一栏会让整个工位看着像坏了 —— 空态是常态,不是缺页。
|
|
*/}
|
|
{detail.data !== undefined ? (
|
|
<ViewRegion
|
|
key={current}
|
|
detail={detail.data}
|
|
onReplyOrder={(reply) => replyOrder.mutate(reply)}
|
|
values={orderValues}
|
|
onField={(k, v) => setOrderValues((old) => ({ ...old, [k]: v }))}
|
|
locate={locate}
|
|
/>
|
|
) : (
|
|
/*
|
|
* **手上没任务时,中栏也得是一张卡**。
|
|
*
|
|
* 其余四个模块(审批 / 消息 / 纪要 / 汇报)的空态都挂 `jc-panel-card`,
|
|
* 只有任务这一栏是裸的 `execution-panel` —— 而那个类在工作区里被显式
|
|
* 去边框去底色(视图区不再有外层卡)。于是同一套工作台上,四个模块的空态
|
|
* 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。
|
|
*/
|
|
<main className="execution-panel jc-panel-card">
|
|
{tasks.isLoading || detailPending ? (
|
|
<div className="jc-list-empty">
|
|
<Ico name="list-checks" s={18} />
|
|
载入中…
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
ico="list-checks"
|
|
tone="brand"
|
|
title="手上没有任务"
|
|
desc="流程走到你负责的活动时,任务会连同办这件事要用的全部上下文一起送到这里 —— 待处理的事、往来记录、产出的文件都在一屏之内。"
|
|
hint="有新任务时左侧列表会亮角标"
|
|
/>
|
|
)}
|
|
</main>
|
|
)}
|
|
<i
|
|
className="jc-vsplit"
|
|
title="拖拽调整对话区宽度 · 双击复位"
|
|
onPointerDown={split.onSplitDown}
|
|
onDoubleClick={split.onSplitReset}
|
|
/>
|
|
<section className="session-chat">
|
|
{detail.data !== undefined ? (
|
|
<TaskChat
|
|
key={current}
|
|
detail={detail.data}
|
|
onLocate={(orderId) => {
|
|
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 }))}
|
|
/>
|
|
) : (
|
|
<div className="jc-tch">
|
|
<header className="jc-tch-head">
|
|
<span className="jc-tch-mark">
|
|
<Ico name="bot" s={15} />
|
|
</span>
|
|
<b className="jc-tch-name">任务助理</b>
|
|
<em className="jc-tch-tag">Agent</em>
|
|
<span className="jc-tch-desc">开一单即可对话</span>
|
|
</header>
|
|
<div className="jc-tch-scroll">
|
|
{detailPending ? (
|
|
<div className="jc-list-empty">
|
|
<Ico name="messages-square" s={18} />
|
|
载入中…
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
ico="messages-square"
|
|
tone="brand"
|
|
title="还没有可对话的任务"
|
|
desc="每一单任务都有自己的对话:AI 有结论、要派工、或者要你拿个主意,都在那一单里说 —— 所以得先有一单在手。"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="copilot-composer session-composer">
|
|
<div className="composer-row">
|
|
<textarea rows={2} disabled placeholder="选中一单任务后就能在这里交代" />
|
|
<button className="copilot-send" disabled>
|
|
<Ico name="send" s={14} /> 发送
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
) : module === 'approvals' ? (
|
|
<ApprovalDesk />
|
|
) : module === 'messages' ? (
|
|
<MessageDesk />
|
|
) : module === 'minutes' ? (
|
|
<MinuteDesk />
|
|
) : (
|
|
<ReportDesk />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 「建设中」提示:点遮罩或按钮关闭,不跳页 */}
|
|
{wip !== null && (
|
|
<div className="jc-wip-overlay" onClick={() => setWip(null)}>
|
|
<div className="jc-wip-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
|
<div className="jc-wip-ico">
|
|
<Ico name="info" s={18} />
|
|
</div>
|
|
<h3>该区域正在建设中</h3>
|
|
<p>「{wip}」暂未开放,敬请期待。</p>
|
|
<button type="button" className="jc-wip-ok" onClick={() => setWip(null)}>
|
|
我知道了
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|