深度拆解任务工作台架构:左右分栏、队列管理、实时刷新全方案

📌 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 &&?

两件事:

  1. size={0} 让面板宽度变成 0(视觉上消失)
  2. 条件渲染让组件彻底不挂载(不执行内部代码、不发请求)

只做第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;  // 选中相关
}
// 💡 分层组织:大的状态对象里套小的,结构清晰

设计思路:每个队列独立维护自己的全部状态。虽然看起来有点"重复",但好处是:

  1. 切换队列时不需要清空再重建,瞬间切换
  2. 每个队列的搜索/筛选/滚动位置都能保留
  3. 可以在后台静默刷新非当前队列的数据

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,任何一个变了都重新检查
}

边界情况处理:

  1. 列表为空 → 清空选中
  2. 无选中项 → 自动选第一条
  3. 选中项被移除 → 自动补位选第一条

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(操作方法) */ };
}

这样做的好处:

  1. 职责单一:每个 Hook 只管自己的事
  2. 可独立测试:可以单独测每个子能力
  3. 可组合:总控制器按需组合
  4. 可替换:某个能力重构不影响其他部分

七、附件管理的双轨制方案

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 架构层面

  1. 三层状态分离:渲染状态(Store)+ 运行时状态(模块变量)+ URL 状态,各司其职
  2. 队列完全独立:每个队列维护完整状态,切换零成本,后台可静默刷新
  3. Hook 组合模式:详情区子能力拆分为独立 Hook,总控制器统一编排,可扩展性强
  4. 刷新分级策略:静默刷新/强制刷新/门控刷新,不同场景用不同策略

9.2 体验层面

  1. 自动选中与补位:列表加载后自动选第一条,项移除后自动补位,减少用户点击
  2. 全局心跳计时:一个 setInterval 驱动所有 SLA 进度条,性能最优
  3. 点击统计即筛选:评价统计数字直接点,所见即所得
  4. URL 状态同步:刷新不丢状态,链接可分享

9.3 工程层面

  1. Action 统一入口:所有状态变更走 action,便于追踪和测试
  2. 请求取消机制:切换队列/搜索时取消旧请求,避免竞态
  3. 附件双轨制:历史数据和新系统数据平滑过渡
  4. 刷新门控:防止重复刷新,保证性能

十、可扩展方向

  1. 键盘快捷键:J/K 上下切换任务,快速处理
  2. 批量操作:选中多个任务批量指派/批量关闭
  3. 自定义队列:用户可以自定义筛选条件保存为个人队列
  4. 拖拽排序:待办队列支持手动调整优先级顺序
  5. 离线缓存:IndexedDB 缓存队列数据,弱网也能看

写在最后

工作台这个页面,说难不难------不就是左右分栏加列表加详情嘛。说简单也不简单------当你把实时刷新、状态同步、多队列、多子模块这些东西都堆上去之后,代码很容易就变成一锅粥。

核心的设计哲学还是那句话:分层、解耦、各司其职。Store 管状态,Action 管变更,Hook 管子能力,组件只管渲染。当你发现某个地方越来越复杂的时候,大概率就是该拆一层了。

希望这篇架构拆解对你有帮助。有问题欢迎在评论区交流 👋


关于本文:基于真实生产项目脱敏整理,技术栈为 React 19 + TypeScript + Vite + Ant Design。文中代码为教学目的做了精简,实际项目中请根据具体需求调整。


📝 小白复习要点

看完这份文档,重点理解这几个架构思想:

概念 一句话总结
多队列独立状态 每个抽屉各管各的,切换快、体验好
发布-订阅模式 状态变了,自动通知所有关心的组件
Action 统一入口 改状态都走我,有记录、有规矩
运行时状态 vs 渲染状态 不需要刷新页面的变量,别放 state 里
节流 throttle 短时间多次触发 → 合并成一次,省性能
门控 gate 正在刷新时别又来一个,排队等
Hook 组合模式 每个功能一个 Hook,像搭乐高一样拼起来
双轨制 新老系统并行,对用户透明
URL 状态同步 刷新不丢、链接可分享

如果你这些都理解了,恭喜你------你已经掌握了一个中大型前端页面的架构设计精髓!🎉

相关推荐
Linye1 小时前
告别“PRD 与原型两张皮”!我们把可交互原生组件直接嵌进了需求文档,还能双向回写 Markdown
前端
deli0071 小时前
RSA 为什么公开公钥也解不开?2 个质数拆开实测一遍
前端
ModyQyW1 小时前
vite-plugin-uni-pages 0.6:统一扫描规则,开发期少重启
前端·uni-app
门思科技1 小时前
把 ThinkLink 接进 Home Assistant 与 Node-RED:一条 MQTT 转发器能走通的路
前端
没事写点啥1 小时前
WeKnora 回答溯源
前端·后端·ai编程
LuHang2 小时前
Vue3 大屏项目 MQTT 实战:机场与无人机 OSD 实时数据总线
前端
2401_885885042 小时前
芬兰语音通知接口怎样对接?语音提醒接入流程
android·前端·python·php·语音识别
铁皮饭盒2 小时前
这6个小模型各有绝活, 且都可cpu/浏览器端运行
前端·后端
徐小夕2 小时前
JitKnow AI知识库 4.7 发布|多文档推理+拖拽 Agent + 技能库,打造更懂业务的 AI 知识中台
前端·vue.js·github