从0到1实现任务评价统计功能:React + TypeScript 实战指南(小白注释版)

📌 写给熟悉 Vue2.3 的你:

这篇文档的每段代码我都会用「小白课堂」的方式给你讲明白。碰到 React 的新概念,我会拿 Vue 里你熟悉的东西做类比,让你一看就懂。

先记住一个最重要的类比:

Vue 2 的概念 React 里对应的东西 一句话解释
data() 里的数据 useState() 定义响应式变量,变了就重新渲染
computed 计算属性 函数里直接算 / useMemo 根据已有数据算出新值
methods 方法 函数组件里定义的函数 处理点击等交互逻辑
mounted 生命周期 useEffect(() => {}, []) 组件第一次渲染完后执行
props 父传子 函数的参数 Props 一模一样,父组件传进来的数据
$emit 子传父 父传一个回调函数当 props React 没有 $emit,靠"父传函数、子调用"来传值
v-for 循环渲染 array.map(item => <div>...</div>) JS 原生的 map 方法
v-if 条件渲染 {条件 && <组件 />} 或三元表达式 JS 里的短路与 / 三元
:class 动态类名 className={{styles.a} {条件 ? styles.b : ''}} 用模板字符串拼类名

🌍 英文词汇小词典(先混个脸熟):

  • evaluation --- 评价、评估
  • stats / statistics --- 统计、数据
  • category --- 分类、类别
  • scale --- 刻度、等级(比如评分的1-5分)
  • payload --- 载荷(就是发给后端的数据体)
  • hook --- 钩子(React 里让函数组件拥有"状态/生命周期"的特殊函数,名字都以 use 开头)
  • callback --- 回调函数
  • subscribe --- 订阅(就像订阅报纸,有新东西就通知你)
  • snapshot --- 快照(当前状态的一个"照片")

前言

在任务协同管理系统中,任务完成后的评价数据是衡量服务质量的重要指标。本文将带你从零实现一个评价统计模块,包含统计柱状条、等级筛选、评价卡片列表等核心功能。

我们采用 React 19 + TypeScript + Vite 技术栈,遵循「API层 → Hook层 → 组件层」的分层架构,保证代码的可维护性和可测试性。

本文基于真实生产项目脱敏整理,所有业务术语已替换为通用表述,核心架构和代码逻辑保持原貌。


一、功能概述与效果展示

1.1 功能点一览

功能点 说明
评价统计条 展示近30天任务评价分布(优秀/合格/不合格),带渐变背景
点击筛选 点击某个评价等级,自动筛选出对应等级的任务列表
评价卡片列表 展示每条评价的任务标题、星级、评语、评价人、时间
详情联动 点击评价卡片,右侧详情区同步展示对应任务
评价提交 在任务详情页可提交评价(1-5分 + 评语)

1.2 整体架构分层

bash 复制代码
┌─────────────────────────────────────────────────────┐
│                    页面层 (Pages)                     │
│   WorkbenchPage → 工作台(左右分栏)                  │
│     └─ EvaluationStatsBar  评价统计柱状条             │
│     └─ EvaluationFilteredList  评价筛选列表           │
├─────────────────────────────────────────────────────┤
│                    Hook 层 (Hooks)                    │
│   useEvaluationStats  ---  统计数据获取与状态管理       │
│   useTaskEvaluation   ---  评价提交逻辑                 │
├─────────────────────────────────────────────────────┤
│                   Service 层 (服务)                    │
│   queue-service  ---  队列筛选与时间范围计算            │
├─────────────────────────────────────────────────────┤
│                    API 层 (请求)                       │
│   requestEvaluationStats   ---  获取评价统计            │
│   requestTaskEvaluation    ---  提交评价                │
├─────────────────────────────────────────────────────┤
│                  Model 层 (类型/规则)                  │
│   evaluation.ts  ---  评价枚举、分类规则、工具函数       │
└─────────────────────────────────────────────────────┘

🎓 小白课堂:为什么要分这么多层?

想象你开了一家餐厅:

  • Model 层 = 菜单和菜谱(规定了有哪些菜、每道菜怎么做)
  • API 层 = 采购员(只管去菜市场买菜,不管你怎么炒)
  • Service 层 = 备菜师傅(把原材料切好、配好,做一些预处理)
  • Hook 层 = 厨师长(管理整个做菜流程,把控状态:菜做好了没?糊了没?)
  • 组件层 = 服务员 + 摆盘(把菜端给客人,只管好不好看、怎么交互)
  • 页面层 = 整个餐厅的布局(桌子怎么摆、客人坐哪儿)

分层的好处是:各司其职,改起来方便。比如哪天想换个采购员(API 层),不会影响厨师长(Hook 层)的工作流程。


二、Model 层:先定义数据模型和规则

好的代码从类型定义开始。评价体系的核心是评分值 和评价等级的映射关系。

🎓 小白课堂:什么是 Model 层?

Model(模型层)就是把"业务规则"和"数据长什么样"集中放在一个地方。

比如:5分算优秀、3-4分算合格、1-2分算不合格------这个规则如果写在 10 个组件里,哪天改成"4分以上算优秀",你就得改 10 个地方,还容易漏。

放在 Model 层就只改一个地方,所有组件自动生效。

对应到 Vue 项目,有点像你把常量和工具函数放在 utils/ 或 constants/ 目录里。

2.1 评价模型定义

typescript 复制代码
// src/models/task/evaluation.ts
//          👆 路径说明:src 是源码根目录,models 放模型文件,
//             task 子目录放任务相关的,evaluation.ts 就是评价相关的模型

/** 评分数值:1-5分 */
// 👇 type 是 TypeScript 关键字,用来定义"类型别名"
//    EvaluationScaleValue 是我们起的名字,它的值只能是 1、2、3、4、5 中的一个
//    这叫"联合类型"(Union Type),就像 Vue 里 props 的 validator 校验一样
export type EvaluationScaleValue = 1 | 2 | 3 | 4 | 5;

/** 评价等级分类 */
// 👇 enum 是 TypeScript 的枚举,就是把一组相关的常量取个好记的名字
//    好处:不用到处写 'excellent' 字符串,不容易写错,还有代码提示
export enum EvaluationCategory {
  Excellent = 'excellent',   // 优秀:5分     ← 左边是代码里用的名字,右边是实际的值
  Pass = 'pass',             // 合格:3-4分
  Fail = 'fail',             // 不合格:1-2分
}
// 💡 小知识:为什么用 enum 而不是直接写字符串?
//    比如你在10个地方写了'excellent',哪天想改成'great',
//    用 enum 只改一个地方就行;直接写字符串要全局搜索替换,还容易改错。

/** 根据分数获取评价等级 */
// 👇 function 就是普通的函数,跟 Vue 的 methods 里写的函数一个意思
//    这里的 : EvaluationCategory 表示函数返回值的类型
export function getEvaluationCategory(score: EvaluationScaleValue): EvaluationCategory {
  // 👆 参数 score 的类型是 EvaluationScaleValue,也就是说只能传 1-5
  if (score >= 5) return EvaluationCategory.Excellent;  // 5分 → 优秀
  if (score >= 3) return EvaluationCategory.Pass;       // 3-4分 → 合格
  return EvaluationCategory.Fail;                       // 1-2分 → 不合格
}
// 💡 设计思路:这个函数就是"评分规则"的集中地。
//    以后规则改了(比如 4分以上算优秀),只改这一个函数就够了。

/** 获取评价等级的中文标签 */
// 👇 传入一个枚举值,返回对应的中文(给用户看的文字)
export function getEvaluationCategoryLabel(category: EvaluationCategory): string {
  // 👇 Record 是 TypeScript 的工具类型
  //    Record<KeyType, ValueType> 表示"键是 KeyType 类型,值是 ValueType 类型"的对象
  //    这里就是:键是 EvaluationCategory 枚举,值是 string(中文标签)
  const labelMap: Record<EvaluationCategory, string> = {
    [EvaluationCategory.Excellent]: '优秀',   // 👈 用枚举值当 key,对应中文
    [EvaluationCategory.Pass]: '合格',
    [EvaluationCategory.Fail]: '不合格',
  };
  return labelMap[category];  // 从映射表里查出来返回
}

/** 全部评价等级列表(用于遍历渲染) */
// 👇 一个常量数组,存了所有评价等级的枚举值
//    为什么要专门定义这个?因为 enum 本身不能直接 .map() 遍历(比较麻烦),
//    定义成数组后,可以直接用 map 来渲染按钮、选项等
export const EVALUATION_CATEGORIES: EvaluationCategory[] = [
  EvaluationCategory.Excellent,
  EvaluationCategory.Pass,
  EvaluationCategory.Fail,
];
// 💡 用法举例:EVALUATION_CATEGORIES.map(cat => <button>{cat}</button>)
//    就像 Vue 里 v-for="cat in EVALUATION_CATEGORIES"

/** 评价记录详情 */
// 👇 interface 是 TypeScript 定义"对象长什么样"的关键字
//    跟 type 有点像,但 interface 更常用于定义对象结构
//    你可以把它理解成 Vue 里 props 的类型定义,或者后端返回数据的"说明书"
export interface TaskEvaluation {
  id: number;               // 评价记录的唯一ID
  task_id: string;          // 对应的任务ID
  task_name: string;        // 任务名称
  evaluator: string;        // 评价人ID(系统内部用的标识)
  evaluator_name: string;   // 评价人姓名(给用户看的)
  score: EvaluationScaleValue;  // 评分(1-5分)
  comment: string;          // 评价评语(用户写的文字)
  updated_at: number;       // Unix时间戳(比如 1700000000,表示从1970年到现在的秒数)
}
// 💡 为什么叫 updated_at 而不是 created_at?
//    因为评价可能被修改(用户改分、改评语),
//    所以显示给用户看的通常是"最后更新时间"。

设计思路:把评分到等级的映射规则集中在 Model 层,而不是散落在各个组件里。后续如果规则变化(比如"合格"改成4-5分),只需要改一个地方。


三、API 层:封装后端接口

API 层只做一件事:发请求、返回数据。不包含任何业务逻辑和状态管理。

🎓 小白课堂:什么是 API 层?

API(Application Programming Interface,应用程序接口)层就是专门管跟后端服务器通信的地方。

就像 Vue 项目里你会把 axios 请求封装到 api/ 目录下,每个接口写成一个函数。

好处:

  1. 组件里不用写 axios.get(...) 这种代码,调用函数就行
  2. 接口地址变了,只改一个地方
  3. 参数校验、错误处理可以统一做

3.1 评价统计接口

typescript 复制代码
// src/pages/task/workbench/api.ts
// 👆 这个 API 文件放在页面目录里,因为它是"工作台页面专用"的接口
//    也有项目会把所有接口都放到 src/api/ 目录下,看团队规范

import { requestApi } from '@/api/request';
// 👆 引入封装好的请求函数(类似 Vue 项目里封装的 axios 实例)
//    @/ 是路径别名,代表 src 目录,跟 Vue 里的 @ 一样
import type { EvaluationScaleValue } from '@/models/task';
// 👆 import type 表示只引入类型(TypeScript 专用),编译后会被删掉,不占体积

/** 分数段计数 */
// 👇 这是后端返回的数据结构:每个分数对应多少条评价
//    比如 { score: 5, count: 10 } 表示"打5分的有10条"
export interface ScoreCountItem {
  score: EvaluationScaleValue;  // 分数(1-5)
  count: number;                // 该分数的评价数量
}

/** 评价统计返回数据 */
// 👇 整个接口返回的数据结构
export interface EvaluationStatsData {
  score_counts: ScoreCountItem[];  // 各分数段的数量(数组,每项是 ScoreCountItem)
  total: number;                   // 总评价数
}

/** 评价统计查询参数 */
// 👇 调用接口时传给后端的参数
export interface EvaluationStatsParams {
  created_at_start_time?: number;  // 创建开始时间(Unix时间戳)
  created_at_end_time?: number;    // 创建结束时间
  // 👆 ? 表示这个属性是"可选的",可以传也可以不传
}

/** 获取评价统计数据 */
// 👇 async 表示这是一个异步函数,返回的是 Promise
//    就像 Vue 里你写 async created() { ... await ... } 一样

export async function requestEvaluationStats(
  params?: EvaluationStatsParams
  // 👆 参数可选(带 ?),调用时可以不传参数
): Promise<EvaluationStatsData> {
  // 👆 返回值类型是 Promise<EvaluationStatsData>
  //    意思是:这是一个 Promise,成功后 resolve 的数据是 EvaluationStatsData 类型

  // ── 参数校验 ─────────────────────────────────
  // 先检查一下开始时间是不是比结束时间还大(不合法)
  if (
    params?.created_at_start_time !== undefined &&
    // 👆 params?.xxx 是"可选链"语法:如果 params 不存在,就返回 undefined,不会报错
    params.created_at_end_time !== undefined &&
    params.created_at_start_time > params.created_at_end_time
  ) {
    throw new RangeError('创建开始时间不能大于创建结束时间');
    // 👆 抛出一个错误,让调用者用 try-catch 去捕获处理
    //    RangeError 是 JS 内置的错误类型,表示"值超出有效范围"
  }

  // ── 拼接 URL 查询参数 ──────────────────────────
  // URLSearchParams 是浏览器原生 API,用来方便地拼接 URL 查询字符串
  // 比如 set('a', '1') 后,toString() 就是 "a=1"
  const query = new URLSearchParams();
  
  if (params?.created_at_start_time !== undefined) {
    query.set('created_at_start_time', String(params.created_at_start_time));
    // 👆 set 方法:设置参数名和值。值必须是字符串,所以用 String() 转一下
  }
  if (params?.created_at_end_time !== undefined) {
    query.set('created_at_end_time', String(params.created_at_end_time));
  }

  const queryString = query.toString();
  // 👆 把参数转成字符串,比如 "created_at_start_time=1700000000&created_at_end_time=1700100000"

  const url = queryString
    ? `/api/v1/task/evaluation/my_statistics?${queryString}`
    // 👆 有参数的话,拼在 ? 后面
    : '/api/v1/task/evaluation/my_statistics';
    // 👆 没参数的话,直接用基础地址
  // 💡 这是三元表达式(三目运算符),跟 Vue 模板里的 {{ condition ? a : b }} 一样

  // ── 发请求 ─────────────────────────────────
  return requestApi<EvaluationStatsData>(url, { method: 'GET' });
  // 👆 调用封装好的请求函数,传两个参数:
  //    1. url:接口地址
  //    2. 配置对象:method 是 'GET'(GET 请求就是获取数据)
  //    <EvaluationStatsData> 是泛型,告诉 TypeScript 返回的数据是什么类型
}

3.2 提交评价接口

typescript 复制代码
// src/pages/task/api.ts
// 👆 这个接口是任务详情页用的,所以放在上一级的 task 目录下

import { requestApi } from '@/api/request';
import type { EvaluationScaleValue } from '@/models/task';

/** 提交评价请求体 */
// 👇 这个 interface 定义了"提交评价时发给后端的数据长什么样"
//    payload(载荷)就是"发给服务器的数据"的意思
export interface TaskEvaluationPayload {
  task_id: string;              // 任务ID(告诉后端是哪个任务的评价)
  score: EvaluationScaleValue;  // 评分(1-5分)
  comment: string;              // 评价文字
}

/** 提交任务评价 */
export async function requestTaskEvaluation(
  payload: TaskEvaluationPayload
  // 👆 必传参数,就是上面定义的请求体
): Promise<void> {
  // 👆 Promise<void> 表示这个异步函数成功后不返回任何数据(void 就是空的意思)
  //    很多 POST 接口都是成功了就返回空,或者只返回 { success: true }
  await requestApi('/api/v1/task/evaluation', {
    method: 'POST',   // POST 请求:提交/创建数据
    data: payload,    // data 就是请求体(POST 的数据放在 body 里)
  });
  // 👆 await 会等请求完成。如果成功,继续往下走(这里后面没代码了,直接结束)
  //    如果失败,会抛出错误,由调用者用 try-catch 处理
}

四、Hook 层:状态管理与业务逻辑

Hook 层负责把 API 调用变成 React 可用的状态,并封装业务逻辑。

🎓 小白课堂:什么是 Hook?为什么需要 Hook?

Hook(钩子) 是 React 特有的概念,名字都以 use 开头。

在 Vue 里,你写一个 .vue 文件,在 data() 里定义响应式数据,在 methods 里写方法,在 mounted 里发请求------这些都是 Vue 框架内置好的能力。

但在 React 里,函数组件本身只是一个"返回 JSX 的函数",默认没有状态、没有生命周期。

Hook 就是让函数组件拥有各种能力的"外挂":

  • useState → 给组件加上"响应式状态"(对应 Vue 的 data)
  • useEffect → 给组件加上"副作用/生命周期"(对应 Vue 的 mounted、watch)
  • useCallback → 缓存一个函数(避免无谓的重新创建)

最酷的是:你可以自己写 Hook! 把一组相关的状态和逻辑封装成一个 useXxx 函数,然后在多个组件里复用。

这就像 Vue 里的 mixins 或者 组合式函数(Composition API 的 setup 里抽出来的函数),但比 mixins 更清晰、来源更明确。

4.1 useEvaluationStats:统计数据 Hook

typescript 复制代码
// src/pages/task/workbench/hooks/use-evaluation-stats.ts
// 👆 hooks 目录放自定义 Hook,文件名通常是 use-xxx 格式

import { useState, useEffect, useCallback } from 'react';
// 👆 从 react 包里引入三个常用 Hook
import {
  requestEvaluationStats,
  type EvaluationStatsData,
} from '../api';
// 👆 引入 API 层的请求函数和类型
import { getEvaluationThirtyDayRange } from '../services/queue-service';
// 👆 引入 service 层的工具函数(计算近30天的时间范围)

// 👇 定义这个 Hook 的状态结构
interface EvaluationStatsState {
  data: EvaluationStatsData | null;  // 统计数据,还没加载时是 null
  isLoading: boolean;                // 是否正在加载中
  error: string | null;              // 错误信息,没有错误就是 null
}
// 💡 为什么要把三个东西包在一个对象里?
//    因为它们是一组相关的状态:发请求 → isLoading = true
//    请求成功 → data = 结果,isLoading = false
//    请求失败 → error = 错误信息,isLoading = false
//    放一起管理更清晰,也方便一次性返回给组件用

// 👇 自定义 Hook,名字必须以 use 开头(这是 React 的约定,也是 Hook 能正常工作的前提)
export function useEvaluationStats() {
  
  // ── 定义状态 ─────────────────────────────────
  // useState 是 React 最基础的 Hook,用来定义"响应式状态"
  // 语法:const [变量, 修改变量的函数] = useState(初始值)
  const [state, setState] = useState<EvaluationStatsState>({
    data: null,       // 初始时没有数据
    isLoading: false, // 初始时不在加载
    error: null,      // 初始时没有错误
  });
  // 💡 Vue 对比:
  //    const [state, setState] = useState(xxx)
  //    相当于 Vue 里的:
  //    data() { return { state: xxx } }
  //    修改时:setState(新值) 相当于 this.state = 新值
  //
  //    注意:React 的 setState 是"替换式"的(你传新对象就整个替换),
  //    所以经常会看到 ...prev 展开旧的,再覆盖要改的属性(下面会看到)。

  // ── 定义获取数据的函数 ────────────────────────
  // useCallback 用来"缓存"一个函数
  // 语法:useCallback(函数体, 依赖数组)
  const fetchStats = useCallback(async () => {
    // 函数体
    setState(prev => ({ ...prev, isLoading: true, error: null }));
    // 👆 setState 可以传一个函数,参数是"旧状态 prev",返回"新状态"
    //    ...prev 是"展开运算符",把旧状态的所有属性展开
    //    然后覆盖 isLoading 和 error 这两个属性
    //    data 保持不变
    //    这叫"函数式更新",当新状态依赖旧状态时推荐这么写
    //
    // 💡 Vue 对比:
    //    this.state.isLoading = true
    //    this.state.error = null
    //    (Vue 直接改就行,React 必须 setState,因为 React 是"不可变数据"思想)

    try {
      // 发请求获取数据,参数是"近30天的时间范围"
      const data = await requestEvaluationStats(getEvaluationThirtyDayRange());
      
      // 请求成功,更新状态:数据有了、不再加载、没有错误
      setState({ data, isLoading: false, error: null });
      // 👆 这里直接传新对象(整个替换),因为成功时所有属性都明确了
      
    } catch (error) {
      // 请求失败,处理错误
      const message =
        error instanceof Error && error.message.trim()
          // 👆 判断 error 是不是 Error 对象,并且有非空的 message
          ? error.message.trim()
          // 如果有后端返回的错误信息,就用它
          : '获取评价统计失败';
          // 否则用默认提示
      
      // 更新状态:保持 data 不变,不再加载,设置错误信息
      setState(prev => ({ ...prev, isLoading: false, error: message }));
    }
  }, []);
  // 👆 依赖数组是空的 [],表示这个函数只会在组件第一次渲染时创建一次,
  //    之后永远复用同一个函数引用。
  //    这就像 Vue 里 methods 里的函数,只会创建一次。
  //
  // 💡 为什么需要 useCallback?直接定义个函数不行吗?
  //    每次组件重新渲染(因为状态变了),函数里的所有代码都会重新执行一遍,
  //    包括 function 的定义(会创建一个新的函数引用)。
  //    如果这个函数被传给子组件当 props,子组件可能会因此做无谓的重新渲染。
  //    useCallback 就是把函数"缓存"起来,依赖不变时,函数引用不变。
  //    简单场景不用也行,但这是好习惯。

  // ── 组件首次渲染时自动获取数据 ────────────────
  // useEffect 是用来处理"副作用"的 Hook
  // 副作用:发请求、操作 DOM、设置定时器 等"不是纯渲染"的事情
  // 语法:useEffect(回调函数, 依赖数组)
  useEffect(() => {
    void fetchStats();
    // 👆 void 的作用:忽略 fetchStats() 返回的 Promise
    //    因为 useEffect 的回调函数不允许直接返回 Promise(它的返回值要用做清理函数)
    //    加个 void 就是告诉 TypeScript:我知道它返回 Promise,但我不用它。
    //    你也可以写成 fetchStats(); 不加 void 也行,只是某些 lint 规则会报警告
  }, [fetchStats]);
  // 👆 依赖数组:当数组里的值变化时,重新执行 effect
  //    这里 fetchStats 被 useCallback 缓存了,所以永远不变,
  //    效果就跟 Vue 的 mounted 一样:只在组件第一次渲染后执行一次
  //
  // 💡 Vue 对比:
  //    useEffect(() => { ... }, [])
  //    相当于 Vue 的 mounted() { ... }
  //
  //    useEffect(() => { ... }, [someVar])
  //    相当于 Vue 的 watch: { someVar: { immediate: true, handler() { ... } } }
  //
  //    useEffect(() => { return () => { 清理 } }, [])
  //    相当于 Vue 的 beforeUnmount() { ... }

  // ── 返回值:给组件用的状态和刷新方法 ──────────
  return { ...state, refresh: fetchStats };
  // 👆 把 state 展开(data、isLoading、error),再加上一个 refresh 方法
  //    组件拿到后就可以:
  //    - 读 data 渲染数据
  //    - 读 isLoading 显示加载状态
  //    - 读 error 显示错误
  //    - 调 refresh() 手动刷新数据
}

4.2 时间范围计算(Service 层)

typescript 复制代码
// src/pages/task/workbench/services/queue-service.ts
// 👆 services 目录放"服务层"的代码------纯业务逻辑,不依赖 React,不碰状态

import dayjs from 'dayjs';
// 👆 dayjs 是一个轻量级的日期处理库(比 moment 小很多,API 差不多)
//    用来处理时间加减、格式化等
import type { EvaluationStatsParams } from '../api';

/** 获取近30天的时间范围(用于评价统计默认查询) */
export function getEvaluationThirtyDayRange(): EvaluationStatsParams {
  const end = dayjs().endOf('day').unix();
  // 👆 dayjs() → 当前时间
  //    .endOf('day') → 今天的结束时刻(23:59:59.999)
  //    .unix() → 转成 Unix 时间戳(秒数)
  const start = dayjs().subtract(30, 'day').startOf('day').unix();
  // 👆 .subtract(30, 'day') → 往前推30天
  //    .startOf('day') → 那天的开始时刻(00:00:00)
  
  return {
    created_at_start_time: start,
    created_at_end_time: end,
  };
  // 👆 返回一个符合 EvaluationStatsParams 类型的对象
  //    直接可以传给 API 函数当参数
}

五、组件层:评价统计柱状条

这是整个模块的视觉核心------一个带渐变背景的统计条,展示优秀/合格/不合格的数量,点击可筛选。

🎓 小白课堂:React 组件是什么样的?

在 Vue 里,一个组件是一个 .vue 文件,包含 <template> + <script> + <style> 三部分。

在 React 里,一个组件就是一个返回 JSX 的函数 。JSX 长得像 HTML,但它其实是 JavaScript(是 React.createElement 的语法糖)。

tsx 复制代码
// Vue 写法
<template>
<div class="box">{{ message }}</div>
</template>

// React 写法
function MyComponent() {
const message = 'Hello';
return <div className="box">{message}</div>;
}

主要区别:

  • React 用 className 而不是 class(因为 class 是 JS 关键字)
  • React 用 {变量名} 插入变量(Vue 用 {``{ }})
  • React 事件用 onClick(小驼峰),Vue 用 @click
  • React 的样式文件通常用 CSS Modules (文件名带 .module.less / .module.css),类名不会冲突,就像 Vue 的 scoped style

5.1 组件实现

tsx 复制代码
// src/pages/task/workbench/components/evaluation-stats-bar/index.tsx
// 👆 组件的主文件,通常叫 index.tsx
//    这样引入时只需要写到目录名就行(自动找 index.tsx)

import { useEvaluationStats } from '../../hooks/use-evaluation-stats';
// 👆 引入我们刚才写的自定义 Hook
import {
  EVALUATION_CATEGORIES,
  getEvaluationCategory,
  getEvaluationCategoryLabel,
  type EvaluationCategory,
  type EvaluationScaleValue,
} from '@/models/task/evaluation';
// 👆 从 Model 层引入枚举、工具函数和类型
import styles from './index.module.less';
// 👆 引入 CSS Modules 样式文件
//    styles 是一个对象,键是你在 less 文件里写的类名,值是编译后唯一的类名字符串
//    比如 styles.evaluationStatsWrapper 可能编译成 "evaluationStatsWrapper_abc123"
//    这样就不会跟别的组件的类名冲突了
//    对应 Vue 里的 <style scoped>

// 👇 定义组件的 Props(接收的参数)
interface Props {
  activeFilter: EvaluationCategory | null;
  // 当前选中的筛选等级,null 表示没选
  isInvolvedQueueActive: boolean;
  // "涉及我的"队列是否处于激活状态(因为评价筛选只在这个队列下生效)
  onStatsFilterClick?: (filter: EvaluationCategory | null) => void;
  // 👆 点击统计项时的回调函数(可选)
  //    这就是 React 的"子传父"方式:父组件传一个函数进来,子组件调用它
  //    对应 Vue 里的 this.$emit('statsFilterClick', filter)
}

// 👇 内部用的数据结构:给渲染用的"加工后"的数据
interface StatsItem {
  key: EvaluationCategory;  // 分类的 key(用于循环的 key,也是点击时传的值)
  label: string;            // 中文标签(给用户看)
  count: number;            // 该分类的数量
}
// 💡 为什么要定义这个?
//    因为 API 返回的是按分数(1-5分)统计的,
//    但我们展示时是按分类(优秀/合格/不合格)展示的,
//    所以需要做一次"聚合",聚合成这个 StatsItem 格式。

// 👇 组件函数定义
export function EvaluationStatsBar({
  activeFilter,
  isInvolvedQueueActive,
  onStatsFilterClick,
}: Props) {
  // 👆 解构赋值:直接从 Props 对象里把需要的属性取出来当变量用
  //    对应 Vue 里声明了 props 后,直接用 this.activeFilter 访问

  // ── 使用自定义 Hook 获取统计数据 ────────────────
  const { data, isLoading } = useEvaluationStats();
  // 👆 调用 Hook,拿到 data(数据)和 isLoading(加载状态)
  //    error 我们这里没用上,所以没解构出来
  //    就像 Vue 里 import 一个 mixin,然后直接用里面的 data 和方法

  // ── 数据聚合:把分数统计转换成分类统计 ──────────
  //    (这相当于 Vue 的 computed 计算属性)
  const items: StatsItem[] = EVALUATION_CATEGORIES.map(category => {
    // 👆 map 是数组的方法,遍历每个元素,返回一个新数组
    //    对应 Vue 模板里的 v-for="category in EVALUATION_CATEGORIES"
    //    但这里不是渲染 DOM,而是"加工数据"

    const count =
      data?.score_counts.reduce(
        // 👆 reduce 是数组方法,用来"累加"出一个值
        //    语法:reduce((累加器, 当前项) => { ... }, 初始值)
        (total, item) =>
          // 👇 判断当前这个分数属于哪个分类
          getEvaluationCategory(item.score as EvaluationScaleValue) === category
            ? total + item.count  // 如果是当前分类,就加上数量
            : total,              // 不是就保持不变
        0  // 初始累加值是 0
      ) ?? 0;
      // 👆 ?? 是空值合并运算符:如果左边是 null 或 undefined,就用右边的 0
      //    data 可能是 null(还没加载完),所以要兜底

    return {
      key: category,
      label: getEvaluationCategoryLabel(category),
      count,
    };
  });
  // 💡 举个例子帮助理解 reduce:
  //    假设 score_counts = [{score:5, count:3}, {score:4, count:5}, {score:2, count:1}]
  //    计算 category = 'pass' 时的 count:
  //    - 第1项 score=5 → 是 excellent,不是 pass → total 保持 0
  //    - 第2项 score=4 → 是 pass → total = 0 + 5 = 5
  //    - 第3项 score=2 → 是 fail,不是 pass → total 保持 5
  //    最终 count = 5

  // ── 点击事件处理函数 ─────────────────────────
  const handleClick = (item: StatsItem) => {
    if (item.count === 0) return;
    // 👆 如果这个分类数量是 0,点击无效(也点不了,因为按钮 disabled 了)
    
    onStatsFilterClick?.(activeFilter === item.key ? null : item.key);
    // 👆 调用父组件传进来的回调函数
    //    onStatsFilterClick?.(...) 中的 ?. 是可选链:如果函数不存在就不调用
    //    
    //    逻辑:如果当前选中的就是这个分类 → 再点一下就取消选中(传 null)
    //          否则 → 选中这个分类(传 item.key)
    //    这叫"开关式"切换,就像开灯关灯一样
  };

  // ── 加载状态的渲染 ───────────────────────────
  if (isLoading && !data) {
    return <div className={styles.skeleton}>加载中...</div>;
  }
  // 👆 第一次加载且没有数据时,显示加载中
  //    这是 React 组件的"条件渲染"写法:提前 return
  //    对应 Vue 的 v-if="isLoading && !data"

  // ── 正常渲染 ────────────────────────────────
  return (
    // 最外层容器
    <div className={styles.evaluationStatsWrapper}>

      {/* SVG 渐变定义 */}
      {/* 💡 这是 SVG 的写法:先定义一个渐变,后面可以通过 id 引用 */}
      {/*    放在 width="0" height="0" 的 svg 里,不会占用空间 */}
      <svg width="0" height="0">
        <defs>
          {/* 线性渐变:从左到右(x1=0% 到 x2=100%) */}
          <linearGradient
            id="evaluation-stats-gradient"
            x1="0%"
            y1="0%"
            x2="100%"
            y2="0%"
          >
            {/* 起点颜色:蓝色,透明度 0.08 */}
            <stop offset="0%" stopColor="#1677ff" stopOpacity="0.08" />
            {/* 终点颜色:绿色,透明度 0.08 */}
            <stop offset="100%" stopColor="#52c41a" stopOpacity="0.08" />
          </linearGradient>
        </defs>
      </svg>

      <div className={styles.evaluationStatsContent}>
        {/* 标题区域 */}
        <div className={styles.header}>
          <span className={styles.title}>评价统计</span>
          <span className={styles.subtitle}>*近30天统计</span>
        </div>

        {/* 统计条主体 */}
        <div
          className={styles.statsBar}
          role="group"
          aria-label="评价统计"
          // 👆 role 和 aria-label 是无障碍属性(A11y)
          //    让屏幕阅读器能读懂这个区域是干什么的
          //    就像给盲人用户"读"界面用的
        >
          {/* 遍历渲染三个统计项:优秀 / 合格 / 不合格 */}
          {items.map(item => {
            // 👇 判断当前项是否处于激活状态
            const isActive =
              activeFilter === item.key && isInvolvedQueueActive;
            // 两个条件都满足才算激活:
            // 1. 当前筛选的是这个分类
            // 2. "涉及我的"队列是激活的(因为评价筛选只在这个队列下显示)

            return (
              <button
                key={item.key}
                // 👆 key 是 React 列表渲染必须的,帮助 React 识别哪些元素变了
                //    就像 Vue 里 v-for 的 :key
                type="button"
                // 👆 设置 type="button",防止在 form 里时误触发表单提交
                
                className={`${styles.statsItem} ${
                  isActive ? styles.active : ''
                } ${item.count === 0 ? styles.disabled : ''}`}
                // 👆 动态类名(用模板字符串拼接)
                //    基础类 statsItem + 激活时加 active + 数量为0时加 disabled
                //    对应 Vue 里的:
                //    :class="[
                //      styles.statsItem,
                //      { [styles.active]: isActive },
                //      { [styles.disabled]: item.count === 0 }
                //    ]"
                
                onClick={() => handleClick(item)}
                // 👆 点击事件,箭头函数包装一下,方便传参数 item
                //    对应 Vue 的 @click="handleClick(item)"
                
                disabled={item.count === 0}
                // 👆 数量为 0 时禁用按钮(点不了)
              >
                {/* 小圆点(不同颜色区分等级) */}
                <span className={`${styles.statsDot} ${styles[item.key]}`} />
                {/* 👆 styles[item.key] 用变量当类名,
                    因为 item.key 是 'excellent'/'pass'/'fail',
                    正好对应 less 里的 &.excellent 等类名 */}
                
                {/* 等级文字 */}
                <span className={styles.statsLabel}>{item.label}</span>
                
                {/* 数量 */}
                <span className={styles.statsCount}>{item.count}</span>
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

5.2 样式实现(Less Modules)

less 复制代码
// src/pages/task/workbench/components/evaluation-stats-bar/index.module.less
// 👆 文件名带 .module.less → 开启 CSS Modules,类名会被加上哈希值,不会冲突

.evaluationStatsWrapper {
  position: relative;        /* 相对定位,作为内部绝对定位元素的参考 */
  margin: 12px 12px 0;       /* 外边距:上12 左右12 下0 */
  border-radius: 8px;        /* 圆角8像素 */
  background: url('data:image/svg+xml;utf8,<svg ...>'); /* 渐变背景(SVG data URL) */
  background-color: #f6ffed; /* 背景色:淡绿 */
  border: 1px solid #b7eb8f; /* 边框:浅绿色 */
}

.evaluationStatsContent {
  padding: 12px 16px;        /* 内边距:上下12 左右16 */
}

.header {
  display: flex;             /* 弹性布局,让子元素横向排列 */
  justify-content: space-between; /* 两端对齐:左边贴左,右边贴右 */
  align-items: center;       /* 垂直居中 */
  margin-bottom: 10px;       /* 底部外边距10像素 */

  .title {
    font-size: 13px;
    font-weight: 600;        /* 字重:半粗体 */
    color: rgba(0, 0, 0, 0.88); /* 黑色,透明度88% */
  }

  .subtitle {
    font-size: 11px;
    color: rgba(0, 0, 0, 0.45); /* 黑色,透明度45%(偏灰) */
  }
}
/* 💡 Less 的嵌套写法:.header .title 可以写成 .header { .title { ... } }
   跟原生 CSS 的后代选择器效果一样,但写起来更有层次 */

.statsBar {
  display: flex;             /* 横向排列 */
  gap: 12px;                 /* 子元素之间的间距12像素 */
}

.statsItem {
  flex: 1;                   /* 弹性增长:三个按钮平分宽度 */
  display: flex;             /* 内部也是弹性布局 */
  flex-direction: column;    /* 方向:纵向(从上到下) */
  align-items: center;       /* 水平居中 */
  gap: 4px;                  /* 子元素间距4像素 */
  padding: 8px 4px;          /* 内边距:上下8 左右4 */
  border: none;              /* 去掉按钮默认边框 */
  border-radius: 6px;        /* 圆角6像素 */
  background: transparent;   /* 背景透明 */
  cursor: pointer;           /* 鼠标移上去变手型 */
  transition: all 0.2s;      /* 所有属性变化,过渡动画0.2秒 */

  &:hover:not(.disabled) {
    /* 👆 & 代表父选择器(即 .statsItem)
       :hover 是鼠标悬停伪类
       :not(.disabled) 表示"不包含 .disabled 类的"
       整体意思:鼠标悬停在未禁用的按钮上时 */
    background: rgba(255, 255, 255, 0.6); /* 半透明白色背景 */
    transform: translateY(-1px);          /* 往上移动1像素(悬浮效果) */
  }

  &.active {
    /* 激活状态(被选中时) */
    background: #fff;                   /* 白色背景 */
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); /* 阴影:立体感 */
  }

  &.disabled {
    /* 禁用状态 */
    opacity: 0.4;                       /* 半透明(看起来灰灰的) */
    cursor: not-allowed;                /* 鼠标变"禁止"图标 */
  }
}

.statsDot {
  width: 8px;
  height: 8px;
  border-radius: 50%;    /* 50%圆角 → 变成圆形 */

  /* 不同等级不同颜色 */
  &.excellent {
    background: #52c41a;  /* 绿色 */
  }
  &.pass {
    background: #faad14;  /* 黄色/橙色 */
  }
  &.fail {
    background: #ff4d4f;  /* 红色 */
  }
}

.statsLabel {
  font-size: 12px;
  color: rgba(0, 0, 0, 0.65);
}

.statsCount {
  font-size: 18px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}

六、组件层:评价筛选卡片列表

点击统计条上的某个等级后,需要展示对应等级的评价列表。每张卡片包含任务ID、星级评分、任务标题、评语、评价人、评价时间。

🎓 小白课堂:组件拆分的思路

这里有两个组件:

  1. EvaluationFilteredList --- 整个列表(大组件)
  2. EvaluationRow --- 列表中的一行/一张卡片(小组件)

为什么要拆成两个?

  • 大组件管"整体逻辑":加载状态、空状态、循环渲染
  • 小组件管"单条渲染":一张卡片长什么样

就像 Vue 里你会把列表的每一项抽成一个 ListItem.vue 子组件一样。

tsx 复制代码
// src/pages/task/workbench/components/evaluation-filtered-list/index.tsx

import { ScoreStars } from './score-stars';
// 👆 引入同目录下的星级评分小组件
import {
  getEvaluationCategory,
  type TaskEvaluation,
} from '@/models/task/evaluation';
import { formatTime } from '@/utils/time';
// 👆 引入时间格式化工具函数(把时间戳转成 "2024-01-15 14:30" 这样的字符串)
import styles from './index.module.less';

// 👇 列表组件的 Props
interface Props {
  evaluations: TaskEvaluation[];
  // 评价数据数组(父组件传进来的,这个组件自己不发请求)
  // 💡 为什么这个组件不自己发请求?
  //    这是"受控组件"的设计思路------数据由父组件管理,
  //    子组件只管"拿到什么数据就渲染什么"。
  //    这样更灵活:数据来源可以是 API、缓存、mock 等等。
  
  isLoading?: boolean;
  // 是否加载中(可选,默认 false)
  
  onSelect?: (taskId: string) => void;
  // 点击某条评价时的回调,传 taskId 给父组件
  
  activeTaskId?: string;
  // 当前选中的任务ID(用来高亮对应卡片)
}

// 👇 列表主组件
export function EvaluationFilteredList({
  evaluations,
  isLoading,
  onSelect,
  activeTaskId,
}: Props) {

  // ── 加载状态 ───────────────────────────
  if (isLoading) {
    return <div className={styles.loading}>加载中...</div>;
  }

  // ── 空状态 ─────────────────────────────
  if (evaluations.length === 0) {
    return <div className={styles.empty}>暂无评价数据</div>;
  }
  // 💡 这两个 if 叫"早期返回"(early return)
  //    先把特殊情况处理掉,后面的代码就是正常的主流程
  //    代码读起来更清爽,不用一层套一层的 if-else

  // ── 正常渲染列表 ────────────────────────
  return (
    <div className={styles.filteredTickets}>
      <div className={styles.list}>
        {/* 遍历评价数组,渲染每一行 */}
        {evaluations.map(evaluation => (
          <EvaluationRow
            key={evaluation.id}
            // 👆 用评价记录的唯一 id 当 key
            
            evaluation={evaluation}
            // 把整条评价数据传给子组件
            
            onSelect={onSelect}
            // 把回调函数继续传给子组件(子组件点击时调用)
            
            isActive={activeTaskId === evaluation.task_id}
            // 计算这一行是否处于激活状态(当前选中的任务ID等于这条的任务ID)
          />
        ))}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────
// 以下是子组件:EvaluationRow(单条评价卡片)
// ─────────────────────────────────────────────

// 👇 行组件的 Props
interface RowProps {
  evaluation: TaskEvaluation;  // 评价数据
  onSelect?: (taskId: string) => void;  // 点击回调
  isActive: boolean;  // 是否激活(高亮)
}

// 👇 行组件(函数声明前面没有 export?
//    对,这个组件只在本文件内使用,不需要导出给外面用)
function EvaluationRow({ evaluation, onSelect, isActive }: RowProps) {

  // 根据分数算出属于哪个等级(用来加边框颜色等样式)
  const category = getEvaluationCategory(evaluation.score);

  return (
    <div
      className={`${styles.card} ${isActive ? styles.active : ''} ${styles[category]}`}
      // 👆 三个类名:基础卡片样式 + 激活高亮 + 等级颜色(绿/黄/红边框)
      
      onClick={() => onSelect?.(evaluation.task_id)}
      // 👆 点击整张卡片,调用父传的回调函数,把任务ID传出去
    >
      {/* 卡片头部:任务ID + 评分 */}
      <div className={styles.cardHeader}>
        <span className={styles.taskId}>{evaluation.task_id}</span>
        
        <div className={styles.scoreWrap}>
          {/* 星级图标 */}
          <ScoreStars score={evaluation.score} />
          {/* 分数文字 */}
          <span className={styles.scoreText}>{evaluation.score}分</span>
        </div>
      </div>

      {/* 任务标题 */}
      <div className={styles.cardTitle} title={evaluation.task_name}>
        {/* 👆 title 属性:鼠标悬停时显示完整文字(应对文字太长被截断的情况) */}
        {evaluation.task_name}
      </div>

      {/* 评价评语 */}
      <div className={styles.cardComment} title={evaluation.comment}>
        {evaluation.comment}
      </div>

      {/* 卡片底部:评价人 + 时间 */}
      <div className={styles.cardFooter}>
        <span className={styles.evaluator}>{evaluation.evaluator_name}</span>
        <span className={styles.time}>{formatTime(evaluation.updated_at)}</span>
        {/* 👆 formatTime 是工具函数,把时间戳(比如 1700000000)
            格式化成 "2023-11-15 08:13" 这样的可读时间 */}
      </div>
    </div>
  );
}

星级评分小组件

tsx 复制代码
// src/pages/task/workbench/components/evaluation-filtered-list/score-stars.tsx
// 👆 这个小组件放在列表组件的目录下,因为是列表专用的

import type { EvaluationScaleValue } from '@/models/task/evaluation';

// 👇 Props 定义
interface Props {
  score: EvaluationScaleValue;  // 分数(1-5)
  size?: number;                // 星星大小(可选,默认 14 像素)
}

// 👇 星级组件
export function ScoreStars({ score, size = 14 }: Props) {
  // 👆 size = 14 是"默认参数":调用时不传 size,就自动用 14
  return (
    <div style={{ display: 'flex', gap: '2px' }}>
      {/* 👆 行内样式(inline style),写法是 style={{ ... }}
         外层 {} 表示这是 JS 表达式,内层 {} 是样式对象
         注意:CSS 属性名用小驼峰,比如 background-color 写成 backgroundColor */}
      
      {/* 渲染 5 颗星星,第 1 到第 score 颗是实心的,后面是空心的 */}
      {[1, 2, 3, 4, 5].map(value => (
        <StarIcon
          key={value}
          filled={value <= score}
          // 👆 filled 是布尔值:当前这个序号 <= 分数,就是实心(点亮的)
          size={size}
        />
      ))}
    </div>
  );
}
// 💡 举例:score = 4 时
//    第1颗:1 <= 4 → 实心 ✓
//    第2颗:2 <= 4 → 实心 ✓
//    第3颗:3 <= 4 → 实心 ✓
//    第4颗:4 <= 4 → 实心 ✓
//    第5颗:5 <= 4 → 空心 ✗

// 👇 内部用的图标组件(不导出,只有本文件用)
function StarIcon({ filled, size }: { filled: boolean; size: number }) {
  // 👆 这里直接把类型写在参数里了,没有单独定义 interface
  //    简单组件可以这样写,少写几行代码
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 20 20"
      // 👆 viewBox 是 SVG 的"画布坐标",跟实际大小无关
      //    这里定义了一个 20x20 的虚拟画布,path 里的坐标都是相对于它的
      
      fill={filled ? '#fadb14' : '#e8e8e8'}
      // 👆 实心时填充黄色(金色),空心时填充灰色
    >
      {/* 这是五角星的 SVG 路径,不用记,设计稿或图标库里直接抄就行 */}
      <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
    </svg>
  );
}

七、接入工作台页面

最后一步,把评价统计条和筛选列表集成到工作台页面中,和队列筛选状态联动。

7.1 队列状态管理(Store 层)

这里用一个精简版的 store 模式(基于 useSyncExternalStore)来管理队列筛选状态:

🎓 小白课堂:什么是 Store?为什么需要它?

当状态需要在多个不相关的组件 之间共享时(比如兄弟组件、隔代组件),

把状态放在各自的 useState 里就不方便了------传 props 要传好多层(这叫"prop drilling",属性钻穿)。

Store(状态仓库) 就是把状态放到一个全局的地方,任何组件都能读、都能改。

对应 Vue 里的 Vuex 或 Pinia。

这里没有用 Redux/Zustand 等第三方库,而是用 React 18 内置的 useSyncExternalStore 自己实现了一个极简版的 store。

核心思想很简单:

  1. 有一个全局的 state 变量(存数据)
  2. 有一个 listeners 集合(存订阅者------哪些组件在用这个 state)
  3. state 变了,就通知所有订阅者(调用它们的 listener 函数,让它们重新渲染)
typescript 复制代码
// src/pages/task/workbench/stores/queue-store.ts

import { useSyncExternalStore } from 'react';
// 👆 React 18 内置的 Hook,用来让组件订阅"外部的状态源"
//    外部状态源 = 不是 React 自己的 useState/useReducer,而是我们自己定义的变量
import type { EvaluationCategory } from '@/models/task/evaluation';

// 👇 队列类型:三个 tab
export type SelectedQueue = 'pending' | 'created' | 'involved';
//    pending  → 待我处理
//    created  → 我创建的
//    involved → 涉及我的

// 👇 Store 的状态结构
interface QueueState {
  selectedQueue: SelectedQueue;
  // 当前选中的队列 tab
  involvedEvaluationCategory: EvaluationCategory | null;
  // "涉及我的"队列下,当前选中的评价筛选等级(null 表示没筛选)
}

// ── 全局状态变量 ────────────────────────────────
// let 声明的普通变量,存在模块作用域里,整个应用共享
let state: QueueState = {
  selectedQueue: 'pending',        // 默认选中"待我处理"
  involvedEvaluationCategory: null, // 默认没有评价筛选
};

// ── 订阅者集合 ──────────────────────────────────
// Set 是 ES6 的数据结构,类似数组,但元素不重复
// 每个订阅者是一个 () => void 的函数,state 变了就调用它
const listeners = new Set<() => void>();

// ── 修改状态的函数 ──────────────────────────────
function setState(partial: Partial<QueueState>) {
  // 👆 Partial<QueueState> 表示"QueueState 的所有属性都是可选的"
  //    也就是说可以只传一部分要改的属性,不用传整个对象
  
  state = { ...state, ...partial };
  // 👆 创建一个新对象:先展开旧 state,再展开 partial(覆盖要改的属性)
  //    这是"不可变更新"的写法:不直接修改原对象,而是创建一个新的
  //    (跟 React setState 的思想一致)
  
  listeners.forEach(listener => listener());
  // 👆 遍历所有订阅者,调用它们的 listener 函数
  //    组件收到通知后,就会重新渲染(拿最新的 state)
}

// ── 订阅函数 ────────────────────────────────────
function subscribe(listener: () => void) {
  listeners.add(listener);  // 加入订阅者集合
  
  // 返回一个"取消订阅"的函数
  return () => listeners.delete(listener);
  // 组件卸载时会调用这个函数,把自己从订阅者里删掉
  // (useSyncExternalStore 会自动处理这件事)
}

// ── 获取当前状态快照 ─────────────────────────────
function getSnapshot() {
  return state;
}
// 👆 每次组件想知道"现在 state 是什么",就调用这个函数
//    它返回的就是当前 state 的引用(快照)

// ── 自定义 Hook:组件用这个来读取 store ──────────
export function useQueueStore() {
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
  // 👆 三个参数:
  //    1. subscribe:订阅函数(state 变化时怎么通知我)
  //    2. getSnapshot:获取当前状态的函数(客户端渲染用)
  //    3. getSnapshot:获取服务端渲染时的快照(这里跟客户端一样)
  //
  // 返回值:就是当前的 state 对象
  // 组件调用 useQueueStore() 就能拿到最新状态,
  // 而且 state 变了组件会自动重新渲染
  //
  // 💡 Vue 对比:
  //    就像 Vue 组件里注入 store,然后 this.$store.state 拿数据,
  //    数据变了组件自动更新
}

// ── Actions:修改状态的方法集合 ──────────────────
// 把所有修改状态的逻辑集中在这里,组件通过调用 action 来改状态
// (不直接暴露 setState,防止乱改)
export const queueActions = {
  
  // 切换选中的队列
  selectQueue(queue: SelectedQueue) {
    setState({ selectedQueue: queue });
  },

  // 设置评价筛选等级
  setEvaluationCategory(category: EvaluationCategory | null) {
    setState({ involvedEvaluationCategory: category });
    
    // 切换评价筛选时,自动切到"涉及我的"队列
    // (因为评价筛选只在"涉及我的"队列下才有意义)
    if (category) {
      setState({ selectedQueue: 'involved' });
    }
  },
};
// 💡 这个 actions 对象就像 Vuex 里的 mutations + actions 的简化版
//    组件不直接改 state,而是调用 actions 里的方法
//    好处:所有修改逻辑集中管理,方便追踪和调试

7.2 工作台页面集成

tsx 复制代码
// src/pages/task/workbench/index.tsx

import { Splitter } from 'antd';
// 👆 Ant Design 的分割面板组件(左右分栏,可以拖动调整宽度)
import { EvaluationStatsBar } from './components/evaluation-stats-bar';
import { EvaluationFilteredList } from './components/evaluation-filtered-list';
import { useQueueStore, queueActions } from './stores/queue-store';
import { TaskDetailPanel } from './components/task-detail-panel';
import styles from './index.module.less';

// 👇 工作台页面主组件
export function WorkbenchPage() {
  // ── 从 store 拿状态 ────────────────────────
  const queueState = useQueueStore();
  // 👆 拿到整个队列状态:{ selectedQueue, involvedEvaluationCategory }

  // ── 处理评价统计条的点击 ────────────────────
  const handleStatsFilterClick = (category: unknown) => {
    // 👆 unknown 是 TypeScript 的类型,表示"不知道是什么类型"
    //    比 any 安全,因为 unknown 不能随便用,必须先"类型断言"或类型判断
    
    queueActions.setEvaluationCategory(category as never);
    // 👆 as never 是类型断言的一种特殊用法,这里是为了绕过类型检查
    //    (实际项目里应该传正确的类型,这句是简化写法)
    //
    // 调用 store 的 action 来修改状态
  };

  // ── 计算"涉及我的"队列是否激活 ───────────────
  const isInvolvedActive = queueState.selectedQueue === 'involved';
  // (相当于 Vue 的 computed)

  // ── 渲染 ──────────────────────────────────
  return (
    <div className={styles.workbench}>
      {/* 左右分栏 */}
      <Splitter>
        {/* 左侧面板:默认宽 320px,最小 240,最大 480 */}
        <Splitter.Panel defaultSize={320} min={240} max={480}>
          <div className={styles.navPanel}>
            
            {/* 评价统计条 */}
            <EvaluationStatsBar
              activeFilter={queueState.involvedEvaluationCategory}
              // 当前选中的评价筛选等级
              isInvolvedQueueActive={isInvolvedActive}
              // "涉及我的"队列是否激活(评价筛选只在这个队列下高亮)
              onStatsFilterClick={handleStatsFilterClick}
              // 点击统计项时的回调:设置筛选等级
            />

            {/* ... 队列 Tab(待我处理 / 我创建的 / 涉及我的)... */}
            {/* ... 搜索框 ... */}
            {/* (这两部分在本教程里省略了,重点讲评价功能) */}

            {/* 条件渲染:
                如果是"涉及我的"队列 + 选中了评价筛选 → 展示评价筛选列表
                否则 → 展示普通队列列表 */}
            {isInvolvedActive && queueState.involvedEvaluationCategory ? (
              // 👆 JSX 里用 && 做条件渲染:条件为真时渲染 && 后面的内容
              //    对应 Vue 的 v-if="isInvolvedActive && involvedEvaluationCategory"
              <EvaluationFilteredList
                // evaluations={...}  从 API 获取
                // onSelect={...}     点击后切换详情
                // (这两个 prop 这里留了占位,实际项目会接上数据)
              />
            ) : (
              <TaskQueueList />
              // 普通队列列表(不是本教程重点,省略了)
            )}
          </div>
        </Splitter.Panel>

        {/* 右侧面板:任务详情 */}
        <Splitter.Panel>
          <TaskDetailPanel />
        </Splitter.Panel>
      </Splitter>
    </div>
  );
}

🎓 小白课堂:整个交互流程串起来讲一遍

用户点击"优秀"统计按钮 → 发生了什么?

bash 复制代码
1. 用户点击 EvaluationStatsBar 里的"优秀"按钮
2. 触发 handleClick → 调用 onStatsFilterClick('excellent')
   (子组件调用父组件传的回调函数)
3. 父组件 WorkbenchPage 的 handleStatsFilterClick 被调用
   → 调用 queueActions.setEvaluationCategory('excellent')
4. setEvaluationCategory 做两件事:
   a. 设置 involvedEvaluationCategory = 'excellent'
   b. 因为 category 不为空,自动设置 selectedQueue = 'involved'
5. setState 触发 → 通知所有 listeners(订阅者)
6. WorkbenchPage 重新渲染:
   - isInvolvedActive 变成 true
   - involvedEvaluationCategory 变成 'excellent'
   - 条件渲染命中,展示 EvaluationFilteredList(评价列表)
7. EvaluationStatsBar 也重新渲染:
   - "优秀"按钮加上 active 类(高亮显示)

是不是很清晰?所有状态都在 store 里,组件只是根据状态渲染 UI。

这就是单向数据流的思想:状态 → 视图 → 交互 → 修改状态 → 视图更新。


八、设计亮点与经验总结

8.1 架构分层清晰

  • Model 层 集中管理枚举、规则、工具函数,避免魔法值散落在各处
    • 魔法值 = 直接写在代码里的字符串/数字,比如到处写 'excellent'、'5' 这种
    • 统一管理后改起来方便,也不容易写错
  • API 层 只负责 HTTP 请求,包含参数校验但不含业务逻辑
    • 它就像一个"传话的",只管把请求发出去、把数据拿回来
    • 不参与"数据怎么处理、状态怎么管"这些事
  • Hook 层 封装状态管理和数据获取,组件只管消费
    • 把"发请求 + 管理 loading/error/data"这一套逻辑封装起来
    • 组件用的时候就一行 const { data, isLoading } = useXxx(),非常清爽
  • 组件层 只做渲染和交互,不直接调用 API
    • 组件应该是"傻的"(dumb component):给我什么数据我就渲染什么
    • 这样组件更容易复用、更容易测试

8.2 点击统计 → 自动筛选 的交互设计

核心交互链路:

bash 复制代码
点击"优秀" → 设置 involvedEvaluationCategory = 'excellent'
           → 自动切换队列到"涉及我的"
           → 列表区从队列列表切换为评价筛选列表
           → 只展示评分为优秀的任务卡片

这种设计把"统计"和"筛选"打通,用户看到数字就能点进去看明细,体验非常顺滑。

8.3 数据聚合在组件层而非 API 层

后端返回的是 { score: 1, count: 3 }, { score: 2, count: 5 }... 这样的细粒度分数计数,前端在组件层聚合成「优秀/合格/不合格」三类。这样做的好处:

  1. 后端接口更通用 :不需要为前端的分类规则做定制
    • 后端就老老实实返回原始数据,不管你前端怎么分
  2. 前端更灵活 :分类规则变化(比如从三类改成四类)不需要改后端
    • 改一个前端函数就够了,不用改接口、不用后端发版
  3. 职责清晰 :展示相关的聚合逻辑归前端
    • 就像"怎么展示"是 UI 的事,"存什么数据"是后端的事

8.4 可访问性(A11y)细节

  • 统计条使用 role="group" + aria-label
    • 屏幕阅读器会读出来:"评价统计,组"
  • 每个统计项是 <button> 而不是 <div>,支持键盘聚焦和回车点击
    • 用 <div> + onClick 也能点,但键盘 Tab 键选不中、回车空格按不了
    • 用原生 <button> 这些功能自带,不用自己写
  • disabled 状态配合 aria-disabled(视具体实现)
    • 告诉屏幕阅读器这个按钮当前不可用

九、可扩展方向

  1. 时间范围选择器:目前固定近30天,可以加上日期范围筛选
  2. 更多维度统计:按处理人、按分类、按优先级等维度的评价分布
  3. 评价趋势图:用 ECharts 画近30天评价分数趋势折线图
  4. 导出功能:支持导出评价明细为 Excel
  5. 评价回复:被评价人可以对评价进行回复说明

写在最后

评价统计模块虽然功能不算复杂,但完整覆盖了「API → Hook → 组件 → 页面集成」的全链路。把这个小模块做好,对理解整个项目的架构分层和数据流有很大帮助。

核心设计哲学是:每层只做自己该做的事。Model 管规则、API 管请求、Hook 管状态、组件管渲染。当你在组件里写着写着发现逻辑越来越复杂时,往往就是该往上抽一层的信号。

希望这篇文章对你有帮助。如果有问题或想法,欢迎在评论区交流 👋


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


📝 小白学习路线建议

如果你是第一次接触 React,建议按这个顺序消化:

顺序 内容 对应章节 难度
1 先看懂 Model 层的 TypeScript 类型定义 第二章 ⭐ 简单
2 看 API 层,理解"接口封装"是怎么回事 第三章 ⭐ 简单
3 重点看 Hook 层,理解 useState / useEffect / useCallback 第四章 ⭐⭐⭐ 重点
4 看简单的组件(ScoreStars → EvaluationRow → EvaluationFilteredList) 第六章 ⭐⭐ 中等
5 看复杂一点的组件(EvaluationStatsBar) 第五章 ⭐⭐ 中等
6 看 Store 层,理解全局状态管理 第七章第一节 ⭐⭐⭐ 重点
7 看页面集成,把整个流程串起来 第七章第二节 ⭐⭐ 综合

学完之后,你可以尝试回答这几个问题来检验自己:

  1. React 里 useState 对应 Vue 里的什么?
  2. React 里子组件怎么把数据传给父组件?(对比 Vue 的 $emit)
  3. 为什么要分 Model / API / Hook / Component 这么多层?
  4. useEffect(() => {}, []) 对应 Vue 的哪个生命周期?
  5. 自定义 Hook 和普通函数有什么区别?

答案都在文档里了,加油!💪

相关推荐
IT_陈寒1 小时前
为什么你应该学习React?
前端·人工智能·后端
ZoeLandia1 小时前
React04:事件处理与受控表单
开发语言·前端·react.js·typescript
亿元程序员1 小时前
用AI做了一款微信小游戏,却发现上传不了
前端
Ai-_Man1 小时前
我需要批量导出Poe左边栏所有对话,而不是一条具体的对话内容,你们AI导出鸭可以吗?
开发语言·前端·javascript·人工智能·小程序
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - Web Storage 本地存储 & 文件 API & 拖放 API
前端·html·html5
恋猫de小郭1 小时前
Flutter 官方表示:AI 时代代码越来越容易写,为什么还要一套代码库?
android·前端·flutter
软件派1 小时前
Chrome 插件开发实战指南
前端·chrome
ly76891 小时前
性能剖析与压测方法论:JMH、async-profiler 与分层瓶颈定位的可复现实验
服务器·前端·数据库·压测·jmh·性能剖析·async-profiler
ZoeLandia2 小时前
React05:useEffect 与副作用
开发语言·前端·react.js·typescript