接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me

This commit is contained in:
shanqin
2026-08-26 13:29:36 +08:00
parent f3397a60ef
commit f01286a89d
40 changed files with 2502 additions and 480 deletions
+96 -41
View File
@@ -1,34 +1,34 @@
/**
* 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。
*
* 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报)→ 轨底身份;
* 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报);身份在顶栏右侧菜单;
* 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。
*
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
*/
import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
import {
listApprovalsForMeRoute,
listMessagesRoute,
listTasksRoute,
logoutRoute,
meRoute,
replyWorkOrderRoute,
sessionRoute,
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';
import { Btn } from '@web-shared/Btn.tsx';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -43,6 +43,10 @@ const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string }
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);
/*
@@ -64,9 +68,9 @@ export function AppShell() {
const split = useChatWidth();
const session = useQuery({
queryKey: queryKeys.session,
queryFn: () => call<Session>(sessionRoute),
const me$ = useQuery({
queryKey: queryKeys.me,
queryFn: () => call<Principal>(meRoute),
});
const tasks = useQuery({
@@ -86,28 +90,39 @@ export function AppShell() {
const list = tasks.data?.tasks ?? [];
const active = list.filter((t) => t.terminal === undefined);
const current = taskId !== '' ? taskId : (active[0]?.id ?? list[0]?.id ?? '');
/*
* 中间区/对话区跟着清单页签走:当前选中落在本页签内才保留,
* 否则落到本页签第一单;页签内一单都没有就是空态 —— 不能隔着页签
* 还摆着另一页签的单(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;
/** 登出后**整体清空缓存**:上一个人的任务、审批、消息不能留在下一个人眼前 */
const logout = useMutation({
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
onSuccess: () => {
queryClient.clear();
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
window.location.assign('/');
},
});
/**
* 登出(ADR-012):先整体清空缓存(上一个人的任务、审批、消息不能留给下一个人),
* 再走 Keycloak end-session 终结 SSO 会话跳回首页 —— 只清本地会「登不出去」(手册 §11)。
* 后端 `/api/logout` 已退役(410),这里不再调它。
*/
const logout = () => {
queryClient.clear();
void kcLogout();
};
const me = session.data?.staff.name ?? '—';
const me = me$.data?.staff.name ?? '—';
/**
* 工单回传 —— 视图区与对话区同一条口。
@@ -141,7 +156,7 @@ export function AppShell() {
<button
key={m.key}
className={module === m.key ? 'active' : ''}
onClick={() => setModule(m.key)}
onClick={() => (m.key === 'tasks' ? setModule('tasks') : setWip(m.label))}
aria-current={module === m.key ? 'page' : undefined}
>
<span className="app-nav-icon-wrap">
@@ -152,24 +167,35 @@ export function AppShell() {
</button>
);
})}
<div className="jc-rail-foot">
{/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */}
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
<span className="jc-nav-name">{me}</span>
</div>
</div>
{/* 轨底不再报身份:头像与姓名挪到顶栏右侧的身份菜单里(2026-08-25 用户定) */}
</nav>
<div className="app-main">
<header className="jc-topbar">
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
<header className="jc-topbar jc-topbar-app">
{/* 顶栏不挂模块名,只留右侧身份菜单(2026-08-25 用户定) */}
<div className="jc-topbar-user">
<span className="jc-topbar-name">{me}</span>
<Btn className="jc-topbar-logout" busy={logout.isPending} onClick={() => logout.mutate()}>
<Ico name="log-out" s={14} />
退出登录
</Btn>
{/* 身份菜单:悬停头像/姓名出下拉 —— 个人中心(建设中提示)+ 退出登录;登出仍是全站唯一入口 */}
<div className="jc-usermenu">
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
<span className="jc-topbar-name">{me}</span>
<Ico name="chevron-down" s={12} />
<div className="jc-usermenu-pop">
{/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */}
<a href="/me">
<Ico name="user" s={14} />
个人中心
</a>
{/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */}
<a href="/design#/design/data/domains">
<Ico name="pen-line" s={14} />
设计中心
</a>
<button type="button" className="danger" onClick={logout}>
<Ico name="log-out" s={14} />
退出登录
</button>
</div>
</div>
</div>
</header>
@@ -198,6 +224,12 @@ export function AppShell() {
setTaskId(id);
}}
onCollapse={() => setCollapsed(true)}
onFilterChange={(s) => {
// 换页签=换一批单:填报缓冲与定位是**过程状态**,不跨单(与 onPick 同一口径)
setOrderValues({});
setLocate(null);
setListFilter(s);
}}
/>
{/*
手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。
@@ -222,7 +254,7 @@ export function AppShell() {
* 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。
*/
<main className="execution-panel jc-panel-card">
{tasks.isLoading ? (
{tasks.isLoading || detailPending ? (
<div className="jc-list-empty">
<Ico name="list-checks" s={18} />
载入中…
@@ -271,12 +303,19 @@ export function AppShell() {
<span className="jc-tch-desc">开一单即可对话</span>
</header>
<div className="jc-tch-scroll">
<EmptyState
ico="messages-square"
tone="brand"
title="还没有可对话的任务"
desc="每一单任务都有自己的对话:AI 有结论、要派工、或者要你拿个主意,都在那一单里说 —— 所以得先有一单在手。"
/>
{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">
@@ -302,6 +341,22 @@ export function AppShell() {
</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>
);
}