📌 Vue 开发者看这里:
这份文档讲的是「工作台页面」的整体架构设计,比上一份(评价统计)更宏观。
碰到新概念我会用 Vue 类比,帮你快速建立认知。
先记住几个核心词:
- workbench --- 工作台
- queue --- 队列(就是任务列表,比如"待办"是一个队列)
- selection --- 选中(当前选中了哪条任务)
- throttle --- 节流(短时间多次触发,只执行一次)
- silent --- 静默的(后台偷偷刷新,用户看不到loading)
前言
任务工作台是任务协同系统中使用频率最高的页面------用户每天打开系统的第一件事,就是在这里处理待办任务。一个好的工作台,需要同时满足高效率 (快速浏览和切换任务)、实时性 (状态变更即时感知)和可扩展性(不断新增的子功能模块)。
本文将从架构设计的角度,完整拆解一个生产级任务工作台的实现方案,涵盖:
- 左右分栏布局与尺寸管理
- 多队列状态架构(待办/我发起的/我参与过)
- 任务选中与详情联动机制
- SSE 实时刷新与节流策略
- 详情区模块化设计
- 附件管理的双轨制方案
本文基于真实生产项目脱敏整理,核心架构和设计思路保持原貌。
一、整体架构总览
1.1 页面布局结构
bash
┌──────────────────────────────────────────────────────────────┐
│ WorkbenchPage │
│ ┌──────────────────────┬─────────────────────────────────┐ │
│ │ 左侧队列面板(350px) │ 右侧详情区域(自适应) │ │
│ │ │ │ │
│ │ ┌──────────────┐ │ ┌───────────────────────────┐ │ │
│ │ │ 评价统计条 │ │ │ 处理流程步骤条 │ │ │
│ │ ├──────────────┤ │ ├───────────────────────────┤ │ │
│ │ │ 队列 Tab │ │ │ 顶部操作栏 │ │ │
│ │ │ [待办][我发起]│ │ ├───────────────────────────┤ │ │
│ │ │ [我参与过] │ │ │ 摘要区 + 附件面板 │ │ │
│ │ ├──────────────┤ │ │ │ │ │
│ │ │ 搜索框 │ │ │ 标题/标签/描述 │ │ │
│ │ ├──────────────┤ │ │ │ │ │
│ │ │ 任务卡片列表 │ │ ├───────────────────────────┤ │ │
│ │ │ │ │ │ 操作记录表 | 时间线 │ │ │
│ │ │ ○ TASK-001 │ │ │ | │ │ │
│ │ │ ○ TASK-002 │ │ │ | │ │ │
│ │ │ ... │ │ └───────────────────────────┘ │ │
│ │ └──────────────┘ │ │ │
│ └──────────────────────┴─────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
🎓 小白课堂:
这就是典型的"左列表 + 右详情"布局,很多后台系统都长这样。
- 左边 = 导航区(选任务)
- 右边 = 内容区(看详情、做操作)
对应到 Vue 项目,就是一个父组件包含两个子组件,通过 Vuex/Pinia 共享"当前选中哪个任务"的状态。
1.2 技术选型
| 类别 | 方案 | 说明 |
|---|---|---|
| 分栏布局 | Ant Design Splitter | 支持拖拽调整、最小/最大宽度 |
| 状态管理 | 自研 Store(发布-订阅) | 基于 useSyncExternalStore,轻量无依赖 |
| 实时推送 | SSE + SharedWorker | 跨标签页共享连接 |
| 列表加载 | LoadMore 模式 | 滚动加载 + 静默刷新 |
| 详情模块化 | Hook 组合模式 | 每个子能力一个 Hook,统一编排 |
🎓 概念速查:
- SSE = Server-Sent Events,服务器主动给前端推消息(类似 WebSocket,但只能服务器→客户端单向)
- SharedWorker = 一种特殊的 Web Worker,多个浏览器标签页可以共享同一个 Worker 实例(节省连接)
- LoadMore = 滚动到底部加载更多(不是分页,是"追加")
- 发布-订阅模式 = Publish-Subscribe,简称 pub/sub。你订阅了某个事件,事件发生时就通知你。对应 Vue 里的
EventBus($on/$emit)
二、布局层:左右分栏的工程化实现
2.1 基础布局
使用 Ant Design 的 Splitter 组件实现分栏,左侧固定宽度 350px,右侧自适应。
tsx
// src/pages/task/workbench/index.tsx
import { Splitter } from 'antd';
import { WorkbenchQueueSection } from './components/workbench-queue-section';
// 👆 左侧队列面板组件
import { WorkbenchInfoController } from './components/workbench-info/controller';
// 👆 右侧详情区控制器组件
import { useWorkbenchRouteState } from './hooks/use-workbench-route-state';
// 👆 路由状态同步的 Hook
import styles from './index.module.less';
// 左侧面板宽度常量(写在外面,方便统一修改)
const QUEUE_PANEL_WIDTH = 350;
export function WorkbenchPage() {
// 调用 Hook 初始化路由状态 + 队列数据
// (这个 Hook 没有返回值,它的作用是"副作用":
// 页面一加载就从 URL 读取参数,初始化状态)
useWorkbenchRouteState();
return (
<div className={styles.workbenchWrapper}>
{/* Splitter = 分割器,左右分栏 */}
<Splitter className={styles.splitter}>
{/* 左侧面板 */}
<Splitter.Panel
defaultSize={QUEUE_PANEL_WIDTH} // 默认宽度 350
min={QUEUE_PANEL_WIDTH} // 最小宽度 350
max={QUEUE_PANEL_WIDTH} // 最大宽度 350
resizable={false} // 不允许拖拽调整
// 👆 min = max 且 resizable = false → 完全固定宽度
>
<WorkbenchQueueSection />
</Splitter.Panel>
{/* 右侧面板(没设置宽度 → 自适应填满剩余空间) */}
<Splitter.Panel>
<WorkbenchInfoController />
</Splitter.Panel>
</Splitter>
</div>
);
}
设计决策 :左侧队列面板设为固定宽度(resizable=false)。为什么不让用户拖?因为队列内容的宽度是经过设计的(任务标题行数、标签排列),宽度变化会破坏布局节奏。对于高频使用的工具型页面,稳定的布局比灵活更重要。
2.2 AI 聊天全屏模式
当 AI 助手展开为全屏时,需要隐藏右侧详情区。实现方式是动态控制右侧面板的 size:
tsx
// 状态:AI聊天是否全屏
const [isChatFullscreen, setIsChatFullscreen] = useState(false);
<Splitter>
<Splitter.Panel ... />
{/* 右侧面板:全屏时宽度设为0(隐藏),否则自适应 */}
<Splitter.Panel size={isChatFullscreen ? 0 : undefined}>
{/* 全屏时不渲染详情组件,节省性能 */}
{!isChatFullscreen && <WorkbenchInfoController />}
</Splitter.Panel>
</Splitter>
💡 为什么设 size=0 还要加
!isChatFullscreen &&?两件事:
size={0}让面板宽度变成 0(视觉上消失)- 条件渲染让组件彻底不挂载(不执行内部代码、不发请求)
只做第1件事的话,组件还在后台运行,还在发请求,浪费资源。
三、Store 层:多队列状态架构
工作台有三个队列(待办/我发起的/我参与过),每个队列都有独立的搜索、筛选、分页状态。如何组织这些状态?
🎓 小白课堂:为什么要三个队列各管各的?
想象你有三个抽屉,每个抽屉放不同的东西。
如果只有一个抽屉,每次换东西都要把原来的拿出来、新的放进去,很慢。
三个队列各有各的状态,切换 tab 时数据已经在那里了,秒切。
用户体验更好。
3.1 状态模型设计
typescript
// src/pages/task/workbench/stores/queue-types.ts
// 👆 types 文件:只放类型定义,不放逻辑
import type { TaskRecord } from '@/models/task';
/** 队列类型(三个tab) */
export type WorkbenchQueue = 'todo' | 'created' | 'involved';
// todo = 待我处理
// created = 我创建的
// involved = 我参与的
/** 通用加载更多列表状态 */
// 👇 泛型 TRecord:列表里每条记录是什么类型,用的时候再指定
export interface LoadMoreListState<TRecord> {
records: TRecord[]; // 列表数据
total?: number; // 总条数(可选)
hasMore: boolean; // 是否还有更多(没加载完)
error: string | null; // 错误信息
isLoading: boolean; // 是否正在加载(第一次加载)
isRefreshing: boolean; // 是否正在刷新(下拉刷新等)
isSilentRefreshing: boolean; // 是否正在静默刷新(后台偷偷刷,用户看不到)
isLoadingMore: boolean; // 是否正在加载更多(滚动到底部加载)
}
// 💡 为什么要分这么多 loading 状态?
// 因为不同场景 UI 表现不一样:
// - isLoading:页面空白,显示骨架屏
// - isRefreshing:显示下拉刷新动画
// - isSilentRefreshing:什么都不显示,用户无感
// - isLoadingMore:底部显示"加载中..."
/** 队列筛选条件 */
export interface QueueFilters {
search: string; // 搜索关键词
includeClosed: boolean; // 是否包含已关闭的
evaluationCategory: string | null; // 评价等级筛选
}
/** 所有队列的状态 */
export interface WorkbenchQueuesState {
// 当前选中的队列(当前在看哪个tab)
selectedQueue: WorkbenchQueue;
// ===== 待办队列 =====
todoQueue: LoadMoreListState<TaskRecord>; // 列表数据状态
todoDraftSearch: string; // 输入框草稿(用户正在打字但还没提交)
todoSubmittedSearch: string; // 已提交的搜索词(实际用来查询的)
todoFilters: QueueFilters; // 筛选条件
// ===== 我创建的队列 =====
createdQueue: LoadMoreListState<TaskRecord>;
createdDraftSearch: string;
createdSubmittedSearch: string;
createdFilters: QueueFilters;
showClosedCreated: boolean; // 这个队列特有的:是否显示已关闭
// ===== 我参与的队列 =====
involvedQueue: LoadMoreListState<TaskRecord>;
involvedDraftSearch: string;
involvedSubmittedSearch: string;
involvedFilters: QueueFilters;
involvedEvaluationCategory: string | null; // 这个队列特有的:评价筛选
}
// 💡 为什么 draft 和 submitted 要分开?
// 搜索框的常见交互:
// - 用户打字时 → 输入框内容跟着变(draft)
// - 用户按回车或点搜索 → 才真正发起查询(submitted)
// 分开存才能实现"输入框里的文字和实际查询的词可能不一样"
/** 选中状态 */
export interface WorkbenchSelectionState {
activeTaskId: string | null; // 当前选中的任务ID(右侧展示哪个任务)
}
/** 工作台页面完整状态 */
export interface WorkbenchPageState {
queues: WorkbenchQueuesState; // 队列相关
selection: WorkbenchSelectionState; // 选中相关
}
// 💡 分层组织:大的状态对象里套小的,结构清晰
设计思路:每个队列独立维护自己的全部状态。虽然看起来有点"重复",但好处是:
- 切换队列时不需要清空再重建,瞬间切换
- 每个队列的搜索/筛选/滚动位置都能保留
- 可以在后台静默刷新非当前队列的数据
3.2 发布-订阅模式的 Store 实现
不使用 Redux / Zustand / MobX,而是用最朴素的发布-订阅模式 + useSyncExternalStore:
typescript
// src/pages/task/workbench/stores/queue-state.ts
// 👆 state 文件:存状态 + 订阅/通知机制
import type { WorkbenchPageState } from './queue-types';
// 全局状态变量(模块级别的,整个页面共享)
let workbenchPageState: WorkbenchPageState = createInitialState();
// 订阅者集合:所有关心状态变化的组件
const listeners = new Set<(state: WorkbenchPageState) => void>();
// ===== 读取状态 =====
// 深拷贝一份返回(防止外部直接修改内部状态)
export function getWorkbenchPageState(): WorkbenchPageState {
return JSON.parse(JSON.stringify(workbenchPageState));
}
// 返回引用(给 useSyncExternalStore 用的,性能更好)
export function getWorkbenchPageStateSnapshot(): WorkbenchPageState {
return workbenchPageState;
}
// ===== 更新状态 =====
// 接收一个"更新函数":旧状态 → 新状态
export function setWorkbenchPageState(
updater: (state: WorkbenchPageState) => WorkbenchPageState
): void {
const nextState = updater(workbenchPageState); // 算出新状态
// Object.is 比较引用:如果新状态和旧状态是同一个对象,说明没真的改
if (Object.is(nextState, workbenchPageState)) return;
workbenchPageState = nextState; // 更新
emitWorkbenchPageState(); // 通知所有订阅者
}
// ===== 订阅 =====
// 订阅状态变化,返回"取消订阅"的函数
export function subscribeWorkbenchPageState(
listener: (state: WorkbenchPageState) => void
): () => void {
listeners.add(listener); // 加入订阅者列表
return () => listeners.delete(listener); // 返回取消订阅的函数
}
// 通知所有订阅者
function emitWorkbenchPageState(): void {
const snapshot = getWorkbenchPageState(); // 拿一份快照
listeners.forEach(listener => listener(snapshot)); // 挨个通知
}
// 创建初始状态
function createInitialState(): WorkbenchPageState {
// ... 返回初始状态对象(省略具体内容)
}
组件中使用:
tsx
import { useSyncExternalStore } from 'react';
import {
subscribeWorkbenchPageState,
getWorkbenchPageStateSnapshot,
} from '../stores/queue-state';
function MyComponent() {
// 用 React 内置的 Hook 订阅外部 store
const state = useSyncExternalStore(
subscribeWorkbenchPageState, // 怎么订阅
getWorkbenchPageStateSnapshot, // 怎么拿当前值
getWorkbenchPageStateSnapshot // SSR 用的,这里也用同一个
);
// 之后 state 变了,组件自动重新渲染
// ...
}
为什么自己实现而不用 Zustand? 因为这个 Store 是页面级的,不需要全局共享,也不需要中间件。几十行代码就能搞定,引入库反而增加依赖和心智负担。
3.3 Action 层:所有状态变更的统一入口
状态变更不能散落在组件里,必须通过 action 函数统一触发。这样才能保证:
- 并发请求不会互相覆盖
- 状态变更有可追溯性
- 多个队列可以联动更新
🎓 小白课堂:什么是 Action?
Action(动作)就是"修改状态的函数"。
为什么不直接让组件改状态?
- 就像公司里每个人都能直接改财务数据,肯定乱套
- 统一走 action,就像所有修改都走财务部门,有记录、有校验、有流程
对应 Vuex 里的
mutations+actions,或者 Pinia 里的actions。
typescript
// src/pages/task/workbench/stores/queue-actions.ts
// 👆 actions 文件:所有修改状态的逻辑都在这里
import { setWorkbenchPageState } from './queue-state';
import type { WorkbenchQueue } from './queue-types';
/** 切换队列(切换tab) */
export function switchWorkbenchQueue(queue: WorkbenchQueue): void {
// 1. 修改状态:把 selectedQueue 改成新的
setWorkbenchPageState(state => ({
...state,
queues: { ...state.queues, selectedQueue: queue },
}));
// 2. 副作用:如果需要,触发数据加载
scheduleQueueLoadIfNeeded(queue);
}
// 💡 action 不仅改状态,还可以触发"副作用"(发请求、定时器等)
// 这就是为什么叫 action 而不叫 mutation
/** 提交搜索 */
export function submitQueueSearch(queue: WorkbenchQueue, search: string): void {
setWorkbenchPageState(state => {
const filters = getQueueFilters(state.queues, queue);
return {
...state,
queues: {
...state.queues,
[`${queue}SubmittedSearch`]: search,
// 👆 模板字符串当属性名:动态生成 key
// 比如 queue='todo' → key 就是 'todoSubmittedSearch'
[`${queue}Filters`]: { ...filters, search: search.trim() },
},
};
});
reloadQueueFromStart(queue); // 重新从第一页加载
}
/** 选中任务 */
export function selectWorkbenchTask(taskId: string): void {
setWorkbenchPageState(state => ({
...state,
selection: { ...state.selection, activeTaskId: taskId },
}));
}
3.4 运行时状态:不参与渲染的那部分
有些状态需要存在,但不需要触发 React 重渲染。比如:
- 当前请求的 AbortController(用于取消请求)
- 请求节流计时器
- 是否已加载过首屏的标记
这些放在运行时状态里,存在普通的模块变量中:
typescript
// src/pages/task/workbench/stores/queue-runtime.ts
interface QueueRuntime {
currentController: AbortController | null; // 当前请求的控制器(用来取消请求)
isThrottled: boolean; // 是否处于节流状态
hasLoadedFirstPage: boolean; // 是否已经加载过第一页
}
// 每个队列一份运行时状态
const runtimeMap: Record<WorkbenchQueue, QueueRuntime> = {
todo: { currentController: null, isThrottled: false, hasLoadedFirstPage: false },
created: { currentController: null, isThrottled: false, hasLoadedFirstPage: false },
involved:{ currentController: null, isThrottled: false, hasLoadedFirstPage: false },
};
export function getQueueRuntime(queue: WorkbenchQueue): QueueRuntime {
return runtimeMap[queue];
}
关键洞察:不是所有状态都需要放进 React 状态树。区分"渲染状态"和"运行时状态",可以减少不必要的重渲染,也让状态管理更清晰。
💡 类比 Vue:就像有些变量你不会放到
data()里(放了就变响应式了,有性能开销),而是直接挂在
this.xxx上或者模块顶部------因为它们不需要驱动视图更新。
四、列表层:任务卡片与队列交互
4.1 任务卡片结构
每张卡片是一个信息密度很高的单元,需要在有限空间内展示关键信息:
tsx
// src/pages/task/workbench/components/workbench-queue-item.tsx
import { Tag } from 'antd';
import { PriorityTag } from './priority-tag'; // 优先级标签
import { EvaluationTag } from './evaluation-tag'; // 评价标签
import { SystemTags } from './system-tags'; // 系统标签
import { SlaProgressBar } from './sla-progress-bar'; // SLA进度条
import { StepTag } from './step-tag'; // 当前步骤标签
import styles from './workbench-queue-item.module.less';
interface Props {
task: TaskRecord; // 任务数据
isActive: boolean; // 是否被选中(高亮)
onClick: () => void; // 点击回调
}
export function WorkbenchQueueItem({ task, isActive, onClick }: Props) {
return (
<div
className={`${styles.item} ${isActive ? styles.active : ''}`}
onClick={onClick}
>
{/* 头部:任务ID + AI图标 */}
<div className={styles.itemHeader}>
<Tag>{task.task_id}</Tag>
{task.has_ai_participant && <AIIcon />}
{/* 👆 有 AI 参与时才显示 AI 图标 */}
</div>
{/* 优先级和评价标签行 */}
<div className={styles.badgeRow}>
<PriorityTag priority={task.priority} />
{task.evaluation && <EvaluationTag score={task.evaluation.score} />}
</div>
{/* 任务标题 */}
<div className={styles.title} title={task.title}>
{task.title}
</div>
{/* 系统标签(比如"加急"、"新任务"等) */}
<SystemTags tags={task.system_tags} />
{/* 底部:SLA进度条 + 当前步骤 */}
<div className={styles.progressRow}>
<SlaProgressBar sla={task.sla} />
<StepTag step={task.current_step} />
</div>
</div>
);
}
💡 设计思路:卡片组件只负责"长什么样",不负责数据获取。
数据由父组件传进来(props),这叫"受控组件" / 展示组件。
对应 Vue 里的"纯展示组件",只通过 props 拿数据。
4.2 自动选中逻辑
列表加载完成后自动选中第一条,是一个很重要的体验细节。但有不少边界情况:
typescript
// src/pages/task/workbench/hooks/use-workbench-active-queue-selection.ts
// 👆 这个 Hook 的作用:自动管理"选中哪条任务"
import { useEffect } from 'react';
import {
selectWorkbenchTask,
clearWorkbenchSelection,
} from '../stores/queue-actions';
import { useWorkbenchCurrentQueue } from './use-workbench-current-queue';
export function useWorkbenchActiveQueueSelection(): void {
// 拿到当前队列的数据
const { queue, records, activeTaskId } = useWorkbenchCurrentQueue();
// 监听变化,自动处理选中
useEffect(() => {
// 情况1:列表空了 → 如果之前有选中,清空
if (records.length === 0) {
if (activeTaskId) clearWorkbenchSelection();
return;
}
// 情况2:没有选中项 → 自动选第一条
if (!activeTaskId) {
selectWorkbenchTask(records[0].task_id);
return;
}
// 情况3:之前选中的那条还在不在列表里?
const stillExists = records.some(r => r.task_id === activeTaskId);
// 👆 some 方法:数组里只要有一个满足条件,就返回 true
if (!stillExists) {
// 不在了(比如被删了、被移走了)→ 补选第一条
selectWorkbenchTask(records[0].task_id);
}
}, [queue, records, activeTaskId]);
// 👆 依赖:队列、列表数据、选中ID,任何一个变了都重新检查
}
边界情况处理:
- 列表为空 → 清空选中
- 无选中项 → 自动选第一条
- 选中项被移除 → 自动补位选第一条
4.3 实时计时:SLA 进度条
SLA 进度条需要实时更新剩余时间,但不能每条记录都开定时器。用一个全局"心跳":
🎓 SLA 是什么?
SLA = Service Level Agreement(服务等级协议)
简单说就是"这个任务必须在多久内处理完"。
进度条显示还剩多少时间,超时了会变红。
typescript
// src/pages/task/workbench/hooks/use-queue-elapsed-now-ms.ts
// 👆 全局心跳 Hook:所有 SLA 进度条共享同一个定时器
import { useState, useEffect } from 'react';
const TICK_INTERVAL = 60_000; // 60秒滴答一次(1分钟更新一次足够了)
// 订阅者集合:所有在使用心跳的组件
const listeners = new Set<(now: number) => void>();
// 全局定时器(整个页面只有一个)
let timer: ReturnType<typeof setInterval> | null = null;
// 👆 ReturnType<typeof setInterval> = setInterval 的返回值类型
// 不同环境下类型不一样(浏览器是 number,Node 是 Timer),
// 用 ReturnType 可以自动推导
// 启动定时器
function startTicker() {
if (timer) return; // 已经启动了就不重复启动
timer = setInterval(() => {
const now = Date.now();
listeners.forEach(fn => fn(now)); // 通知所有订阅者:"现在时间是 now"
}, TICK_INTERVAL);
}
// 停止定时器
function stopTicker() {
if (!timer) return;
clearInterval(timer);
timer = null;
}
// Hook:组件用它来获取"当前时间",每分钟自动更新
export function useQueueElapsedNowMs(): number {
const [now, setNow] = useState(Date.now());
useEffect(() => {
// 定义一个监听器
const listener = (n: number) => setNow(n);
listeners.add(listener); // 加入订阅
startTicker(); // 确保定时器在跑
// 清理函数:组件卸载时
return () => {
listeners.delete(listener); // 取消订阅
if (listeners.size === 0) stopTicker();
// 👆 如果没人用了,就把定时器停掉(省电、省性能)
};
}, []);
return now;
}
优化思路:全局只开一个 setInterval,所有 SLA 进度条共享同一个心跳。当没有组件使用时自动停止,避免资源浪费。
💡 为什么每条不自己开定时器?
假如列表有 50 条任务,就是 50 个 setInterval,浏览器会卡。
全局一个定时器,每分钟统一通知大家更新,性能好得多。
五、实时刷新机制:SSE + 节流策略
5.1 SSE 事件订阅
通过 SharedWorker 与后端建立 SSE 连接,监听任务状态变更事件:
🎓 什么是 SSE?
SSE = Server-Sent Events(服务器发送事件)
前端建立连接后,后端可以随时主动给前端推消息。
就像你订阅了一个公众号,有新文章它会推给你。
和 WebSocket 的区别:
- SSE 是单向的:只能服务器→前端
- WebSocket 是双向的:两边都能发
任务状态变更这种场景,SSE 足够了,更轻量。
typescript
// src/pages/task/workbench/hooks/use-workbench-realtime-refresh.ts
import { useEffect } from 'react';
import { refreshWorkbenchQueuesSilently } from '../stores/queue-actions';
import { eventBus } from '@/services/event-bus';
// 👆 eventBus = 事件总线,全局的 pub/sub 工具
// 对应 Vue 里的 EventBus(new Vue() 当 bus 用,$on/$emit)
export function useWorkbenchRealtimeRefresh(): void {
useEffect(() => {
// 订阅多个事件,收到任何一个都触发静默刷新
const unsubscribers = [
// 任务状态变了(比如从"待处理"变成"处理中")
eventBus.on('task.statusChanged', () => {
refreshWorkbenchQueuesSilently();
}),
// 任务数量变了(比如新增了一个待办)
eventBus.on('task.countChanged', () => {
refreshWorkbenchQueuesSilently();
}),
// SSE 重连成功(之前断了,现在连上了,得刷新一下确保数据最新)
eventBus.on('sse.reconnected', () => {
refreshWorkbenchQueuesSilently();
}),
];
// 清理:组件卸载时取消所有订阅
return () => unsubscribers.forEach(fn => fn());
}, []);
}
5.2 静默刷新与节流
短时间内可能收到多个 SSE 事件,不能每个都发请求。用节流合并:
🎓 什么是节流(throttle)?
节流 = 在一段时间内,不管触发多少次,只执行一次。
生活例子:电梯门。在关门时间内,每次有人进来都会"重置"关门时间吗?
不对,那是防抖(debounce)。
节流是:3秒内我只发一次请求 。3秒内来了10个事件,只合并成1次请求。
这样可以避免短时间内狂发请求,给服务器减轻压力。
typescript
// src/pages/task/workbench/stores/queue-throttle.ts
const REFRESH_THROTTLE_MS = 3_000; // 3秒节流窗口
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingRefresh = false; // 节流窗口内是否有"待执行"的刷新
export function scheduleSilentRefresh(): void {
pendingRefresh = true; // 标记:有一个刷新在等着
if (throttleTimer) return;
// 👆 如果已经在节流窗口内(定时器还在),就不重复设置
// 反正 pendingRefresh 已经设为 true 了,时间到了会执行
// 设置定时器:3秒后执行
throttleTimer = setTimeout(() => {
throttleTimer = null; // 重置定时器标记
if (pendingRefresh) {
pendingRefresh = false;
doRefreshAllQueuesSilently(); // 真正执行刷新
}
}, REFRESH_THROTTLE_MS);
}
刷新策略分级:
| 触发场景 | 刷新方式 | 说明 |
|---|---|---|
| SSE 事件推送 | 静默刷新 | 后台刷新,不显示 loading,有节流 |
| 用户主动操作后 | 强制刷新 | 立即取消当前请求,重新加载,无节流 |
| 切回标签页 | 静默刷新 | visibilitychange 触发 |
| 手动点击刷新 | 强制刷新 | 显示 loading 状态 |
5.3 详情刷新门控
右侧详情区也会被多次触发刷新(操作后、SSE 事件等),需要一个门控防止重复刷新:
🎓 什么是门控(gate)?
就像一个门:
- 门开着 → 可以进去刷新
- 门关着 → 先在外面等着,等门开了再说
防止"正在刷新时又来一个刷新请求,两个请求同时跑"的情况。
typescript
// src/pages/task/workbench/components/workbench-info/refresh-gate.ts
interface RefreshGate {
isRefreshing: boolean; // 正在刷新中?(门关着?)
pending: boolean; // 有没有在等的刷新请求?
throttleUntil: number; // 节流截止时间戳(这个时间之前不刷新)
}
const gate: RefreshGate = {
isRefreshing: false,
pending: false,
throttleUntil: 0,
};
// 带门控的刷新函数
export async function guardedRefresh(refreshFn: () => Promise<void>): Promise<void> {
// 情况1:正在刷新 → 标记 pending,直接返回(等当前刷新完再说)
if (gate.isRefreshing) {
gate.pending = true;
return;
}
// 情况2:还在节流期内 → 不刷新
if (Date.now() < gate.throttleUntil) {
return;
}
// 情况3:可以刷新了
gate.isRefreshing = true; // 关门
try {
await refreshFn(); // 执行真正的刷新
gate.throttleUntil = Date.now() + 2_000; // 刷新完后2秒内不接受再刷新
} finally {
// finally = 不管成功失败都会执行
gate.isRefreshing = false; // 开门
if (gate.pending) {
gate.pending = false;
guardedRefresh(refreshFn); // 递归:如果有待执行的,继续刷
}
}
}
六、详情区:模块化 Hook 组合架构
右侧详情区是整个工作台最复杂的部分------流程、操作栏、摘要、附件、时间线、操作记录、评价......功能不断叠加。如何保证代码不变成"一坨"?
答案是:每个子能力一个 Hook,由一个总控制器统一编排。
🎓 小白课堂:什么是 Hook 组合模式?
想象你在搭乐高:
- 每个小功能块是一个 Hook(轮子、窗户、门)
- 总控制器是"底座",把各个零件拼起来
对应 Vue 的话,有点像 Vue 3 Composition API 里的"组合式函数"------
把相关的逻辑抽成一个
useXxx函数,在 setup 里调用。好处是:功能模块化了,加新功能不会让主组件越来越臃肿。
6.1 总控制器模式
typescript
// src/pages/task/workbench/components/workbench-info/use-controller.ts
// 👆 总控制器 Hook:把所有子能力的 Hook 组装起来
import { useState, useCallback } from 'react';
import { useWorkbenchInfoDetail } from '../workbench-info-detail/use-detail';
import { useWorkbenchInfoFlow } from '../workbench-info-flow/use-flow';
import { useWorkbenchInfoAttachments } from '../workbench-info-attachments/use-attachments';
import { useWorkbenchInfoTimeline } from '../workbench-info-timeline/use-timeline';
import { useWorkbenchInfoEvaluation } from '../workbench-info-evaluation/use-evaluation';
import { guardedRefresh } from './refresh-gate';
// 入参:当前任务ID(null 表示没选中任务)
export function useWorkbenchInfoController(taskId: string | null) {
// 1. 详情基础数据(最核心的,其他子能力都依赖它)
const detail = useWorkbenchInfoDetail(taskId);
// 2. 流程步骤(依赖 detail.data)
const flow = useWorkbenchInfoFlow(detail.data);
// 3. 附件(依赖 detail.data,附件变更后触发回调)
const attachments = useWorkbenchInfoAttachments(detail.data, {
onChanged: detail.refreshAfterMutation,
});
// 4. 时间线
const timeline = useWorkbenchInfoTimeline(detail.data);
// 5. 评价
const evaluation = useWorkbenchInfoEvaluation(detail.data, {
onSubmitted: detail.refreshAfterMutation,
});
// 统一刷新入口(带门控)
const refresh = useCallback(async () => {
if (!taskId) return;
await guardedRefresh(() => detail.refresh());
}, [taskId, detail]);
// 把所有东西打包返回给组件用
return {
task: detail.data,
isLoading: detail.isLoading,
error: detail.error,
refresh,
flow,
attachments,
timeline,
evaluation,
// ... 更多子能力
};
}
💡 设计很清晰:
- 每个子能力一个 Hook,互不干扰
- 总控制器负责"组装"和"编排"
- 加新功能 = 加一个新 Hook + 在总控制器里加一行
- 不会把主组件搞得乱七八糟
6.2 子 Hook 的统一接口规范
每个子 Hook 遵循同样的设计模式:
typescript
// 输入:detail 数据 + 回调选项
// 输出:该子能力的 state + actions
function useXxxCapability(detail: TaskDetail | null, options?: { onChanged?: () => void }) {
// ... 内部状态
// ... 内部方法
return { /* state(数据) */, /* actions(操作方法) */ };
}
这样做的好处:
- 职责单一:每个 Hook 只管自己的事
- 可独立测试:可以单独测每个子能力
- 可组合:总控制器按需组合
- 可替换:某个能力重构不影响其他部分
七、附件管理的双轨制方案
7.1 为什么是双轨制?
老系统的历史附件只能下载不能管理,新系统的附件走新的 CMS 服务支持增删改查。两边同时存在,怎么办?
答案是双轨制:两种数据来源统一展示,但操作路径分离。
🎓 什么是双轨制?
就像新修了一条高速公路,但老路还没拆:
- 新路:新系统附件 → 能上传、能删除、能管理
- 老路:历史附件 → 只能看、能下载,但不能删改
- 对用户来说:都在同一个地方显示,不用关心是新的还是老的
这是系统迁移过渡期常见的方案。
tsx
// src/components/file-group/file-group-attachment.tsx
interface Props {
fileGroupNo: string; // 附件组编号
editable?: boolean; // 是否可编辑
source: AttachmentSource; // CMS 附件数据源(新系统)
readonlyAttachments?: ReadonlyAttachment[]; // 只读历史附件(老系统)
previewLimit?: number; // 预览数量限制
onChanged?: (change: AttachmentChange) => void;
}
export function FileGroupAttachment({
fileGroupNo,
editable,
source,
readonlyAttachments = [],
previewLimit = 2,
onChanged,
}: Props) {
// 从新系统数据源获取附件(可管理)
const { attachments, isLoading, upload, remove } = useAttachmentSource(source);
// 合并展示:新附件 + 老附件(用户看到的是一个列表)
const allAttachments = [...attachments, ...readonlyAttachments];
// 删除时区分:老附件不能删,新附件可以删
const handleRemove = (fileId: string) => {
const isReadonly = readonlyAttachments.some(a => a.id === fileId);
if (isReadonly) return; // 老附件,不允许删
remove(fileId); // 新附件,调用删除
};
return (
<div className={styles.wrapper}>
{/* 预览区 */}
<div className={styles.preview}>
{allAttachments.slice(0, previewLimit).map(file => (
<AttachmentPreviewItem
key={file.id}
file={file}
onRemove={editable ? () => handleRemove(file.id) : undefined}
/>
))}
{allAttachments.length > previewLimit && (
<div className={styles.more}>+{allAttachments.length - previewLimit}</div>
)}
</div>
{/* 管理入口(只有可编辑时才显示) */}
{editable && (
<button onClick={openManager}>管理附件</button>
)}
</div>
);
}
7.2 数据源接口抽象
通过 AttachmentSource 接口隔离具体实现,方便后续切换或 Mock:
typescript
// 定义"附件数据源"应该长什么样(接口 = 契约)
export interface AttachmentSource {
load: () => Promise<AttachmentItem[]>; // 加载附件列表
upload: (files: File[]) => Promise<AttachmentItem[]>; // 上传
remove: (fileId: string) => Promise<void>; // 删除
getDownloadUrl: (fileId: string) => string; // 获取下载链接
}
💡 为什么要定义接口?
这叫"面向接口编程"。
只要满足这个接口,具体实现可以换:
- 正式环境用 CMS 服务实现
- 测试环境用 Mock 数据实现
- 以后换供应商,只要写个新的实现类就行,组件代码不用动
对应 Vue 里的"依赖注入"或"策略模式"的思想。
八、路由与深链接
8.1 URL 参数驱动状态
工作台的状态需要和 URL 同步,这样用户可以:
- 刷新页面后回到原来的位置
- 分享链接给同事,对方打开就是同一个任务
typescript
// src/pages/task/workbench/hooks/use-workbench-route-state.ts
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
// 👆 react-router-dom 提供的 Hook:读取/修改 URL 上的查询参数
// 比如 URL 是 /workbench?queue=todo&active=TASK-001
// searchParams.get('queue') → 'todo'
import {
switchWorkbenchQueue,
selectWorkbenchTask,
} from '../stores/queue-actions';
export function useWorkbenchRouteState() {
const [searchParams, setSearchParams] = useSearchParams();
// 方向1:URL → Store(页面加载时,从 URL 读取状态)
useEffect(() => {
const queue = searchParams.get('queue') as WorkbenchQueue | null;
const active = searchParams.get('active');
if (queue) switchWorkbenchQueue(queue);
if (active) selectWorkbenchTask(active);
}, []);
// 👆 空依赖 = 只在组件首次渲染时执行一次
// 方向2:Store → URL(状态变化时,同步到 URL)
// ... 订阅 store 变化,更新 searchParams
// (文档里省略了具体实现,思路就是两边保持同步)
}
🎓 深链接(Deep Link)是什么?
就是"直接打开应用内某个具体页面/状态"的链接。
比如你发一个链接给同事,他点开直接就是第3个tab、选中某个任务------
而不是还得自己点一遍。
8.2 外部跳转精确查找
当从其他系统跳转过来,只带了任务 ID,需要自动定位到对应的队列并选中:
typescript
// 在三个队列中挨个找,找到后切换并选中
async function locateTaskInQueues(taskId: string): Promise<void> {
const queues: WorkbenchQueue[] = ['todo', 'created', 'involved'];
// 依次遍历三个队列
for (const queue of queues) {
const found = await searchTaskInQueue(queue, taskId);
if (found) {
switchWorkbenchQueue(queue); // 切到那个队列
selectWorkbenchTask(taskId); // 选中那个任务
return; // 找到了就退出
}
}
}
九、设计亮点总结
9.1 架构层面
- 三层状态分离:渲染状态(Store)+ 运行时状态(模块变量)+ URL 状态,各司其职
- 队列完全独立:每个队列维护完整状态,切换零成本,后台可静默刷新
- Hook 组合模式:详情区子能力拆分为独立 Hook,总控制器统一编排,可扩展性强
- 刷新分级策略:静默刷新/强制刷新/门控刷新,不同场景用不同策略
9.2 体验层面
- 自动选中与补位:列表加载后自动选第一条,项移除后自动补位,减少用户点击
- 全局心跳计时:一个 setInterval 驱动所有 SLA 进度条,性能最优
- 点击统计即筛选:评价统计数字直接点,所见即所得
- URL 状态同步:刷新不丢状态,链接可分享
9.3 工程层面
- Action 统一入口:所有状态变更走 action,便于追踪和测试
- 请求取消机制:切换队列/搜索时取消旧请求,避免竞态
- 附件双轨制:历史数据和新系统数据平滑过渡
- 刷新门控:防止重复刷新,保证性能
十、可扩展方向
- 键盘快捷键:J/K 上下切换任务,快速处理
- 批量操作:选中多个任务批量指派/批量关闭
- 自定义队列:用户可以自定义筛选条件保存为个人队列
- 拖拽排序:待办队列支持手动调整优先级顺序
- 离线缓存:IndexedDB 缓存队列数据,弱网也能看
写在最后
工作台这个页面,说难不难------不就是左右分栏加列表加详情嘛。说简单也不简单------当你把实时刷新、状态同步、多队列、多子模块这些东西都堆上去之后,代码很容易就变成一锅粥。
核心的设计哲学还是那句话:分层、解耦、各司其职。Store 管状态,Action 管变更,Hook 管子能力,组件只管渲染。当你发现某个地方越来越复杂的时候,大概率就是该拆一层了。
希望这篇架构拆解对你有帮助。有问题欢迎在评论区交流 👋
关于本文:基于真实生产项目脱敏整理,技术栈为 React 19 + TypeScript + Vite + Ant Design。文中代码为教学目的做了精简,实际项目中请根据具体需求调整。
📝 小白复习要点
看完这份文档,重点理解这几个架构思想:
| 概念 | 一句话总结 |
|---|---|
| 多队列独立状态 | 每个抽屉各管各的,切换快、体验好 |
| 发布-订阅模式 | 状态变了,自动通知所有关心的组件 |
| Action 统一入口 | 改状态都走我,有记录、有规矩 |
| 运行时状态 vs 渲染状态 | 不需要刷新页面的变量,别放 state 里 |
| 节流 throttle | 短时间多次触发 → 合并成一次,省性能 |
| 门控 gate | 正在刷新时别又来一个,排队等 |
| Hook 组合模式 | 每个功能一个 Hook,像搭乐高一样拼起来 |
| 双轨制 | 新老系统并行,对用户透明 |
| URL 状态同步 | 刷新不丢、链接可分享 |
如果你这些都理解了,恭喜你------你已经掌握了一个中大型前端页面的架构设计精髓!🎉