📌 写给熟悉 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/目录下,每个接口写成一个函数。好处:
- 组件里不用写
axios.get(...)这种代码,调用函数就行- 接口地址变了,只改一个地方
- 参数校验、错误处理可以统一做
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、星级评分、任务标题、评语、评价人、评价时间。
🎓 小白课堂:组件拆分的思路
这里有两个组件:
EvaluationFilteredList--- 整个列表(大组件)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。核心思想很简单:
- 有一个全局的
state变量(存数据)- 有一个
listeners集合(存订阅者------哪些组件在用这个 state)- 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>
);
}
🎓 小白课堂:整个交互流程串起来讲一遍
用户点击"优秀"统计按钮 → 发生了什么?
bash1. 用户点击 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 }... 这样的细粒度分数计数,前端在组件层聚合成「优秀/合格/不合格」三类。这样做的好处:
- 后端接口更通用 :不需要为前端的分类规则做定制
- 后端就老老实实返回原始数据,不管你前端怎么分
- 前端更灵活 :分类规则变化(比如从三类改成四类)不需要改后端
- 改一个前端函数就够了,不用改接口、不用后端发版
- 职责清晰 :展示相关的聚合逻辑归前端
- 就像"怎么展示"是 UI 的事,"存什么数据"是后端的事
8.4 可访问性(A11y)细节
- 统计条使用
role="group"+aria-label- 屏幕阅读器会读出来:"评价统计,组"
- 每个统计项是
<button>而不是<div>,支持键盘聚焦和回车点击- 用
<div>+ onClick 也能点,但键盘 Tab 键选不中、回车空格按不了 - 用原生
<button>这些功能自带,不用自己写
- 用
disabled状态配合aria-disabled(视具体实现)- 告诉屏幕阅读器这个按钮当前不可用
九、可扩展方向
- 时间范围选择器:目前固定近30天,可以加上日期范围筛选
- 更多维度统计:按处理人、按分类、按优先级等维度的评价分布
- 评价趋势图:用 ECharts 画近30天评价分数趋势折线图
- 导出功能:支持导出评价明细为 Excel
- 评价回复:被评价人可以对评价进行回复说明
写在最后
评价统计模块虽然功能不算复杂,但完整覆盖了「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 | 看页面集成,把整个流程串起来 | 第七章第二节 | ⭐⭐ 综合 |
学完之后,你可以尝试回答这几个问题来检验自己:
- React 里
useState对应 Vue 里的什么? - React 里子组件怎么把数据传给父组件?(对比 Vue 的
$emit) - 为什么要分 Model / API / Hook / Component 这么多层?
useEffect(() => {}, [])对应 Vue 的哪个生命周期?- 自定义 Hook 和普通函数有什么区别?
答案都在文档里了,加油!💪