Archived
接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user