From 9565a0f23cc746ab1429253fdde080894a0c9f98 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 18:37:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=B1=E5=8C=96=20TanStack=20?= =?UTF-8?q?Query=20=E6=95=B0=E6=8D=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 queryKeys.ts 统一 QueryKey 工厂(覆盖全部模块,invalidate/refetch/prefetch 同源) - 新增 queryClient.ts 全局配置:retry 1、staleTime 30s、gcTime 5min、refetchOnWindowFocus false - 新增 useApiQuery:zod schema 校验 + 类型收敛 + select 转换,消除页面重复 validateResponse 样板 - useApiMutation 支持 onMutate/onSettled/context(乐观更新) - 示范迁移:Organizations(useApiQuery + 乐观更新归档/恢复)、Students(useApiQuery + queryKeys + 打开抽屉前 prefetch 档案聚合) - StudentProfileContent queryKey 统一为 queryKeys.archive.detail,prefetch 可命中缓存 --- apps/admin/src/api/queryClient.ts | 20 +++ apps/admin/src/api/queryKeys.ts | 145 ++++++++++++++++++ .../StudentProfileContent/index.tsx | 5 +- apps/admin/src/hooks/useApiMutation.ts | 46 ++++-- apps/admin/src/hooks/useApiQuery.ts | 31 ++++ apps/admin/src/main.tsx | 12 +- apps/admin/src/pages/Organizations/index.tsx | 51 ++++-- apps/admin/src/pages/Students/index.tsx | 63 +++++--- 8 files changed, 310 insertions(+), 63 deletions(-) create mode 100644 apps/admin/src/api/queryClient.ts create mode 100644 apps/admin/src/api/queryKeys.ts create mode 100644 apps/admin/src/hooks/useApiQuery.ts diff --git a/apps/admin/src/api/queryClient.ts b/apps/admin/src/api/queryClient.ts new file mode 100644 index 0000000..d0bb807 --- /dev/null +++ b/apps/admin/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * 全局 QueryClient:统一缓存/重试策略。 + * - retry 1:接口失败最多重试 1 次,避免瞬时错误直接白屏 + * - staleTime 30s:30 秒内重复请求走缓存 + * - gcTime 5min:不活跃缓存 5 分钟后回收 + * - refetchOnWindowFocus false:切回窗口不自动全量刷新, + * 保活页面由 useVisibleRefetch 按需刷新,避免重复请求 + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/admin/src/api/queryKeys.ts b/apps/admin/src/api/queryKeys.ts new file mode 100644 index 0000000..fabb82b --- /dev/null +++ b/apps/admin/src/api/queryKeys.ts @@ -0,0 +1,145 @@ +/** + * 统一 QueryKey 工厂。 + * + * 每个模块一个命名空间,key 由工厂函数生成: + * - 避免散落字符串字面量导致拼写不一致、缓存串扰 + * - invalidate / refetch / prefetch 与 useQuery 使用同一来源,不会对不上 + * + * 约定: + * - `all` 是该模块的「根 key」,用于整体失效(invalidateQueries 会匹配前缀) + * - 列表 key 按过滤条件展开;详情/子资源用固定段 + 参数 + */ +export const queryKeys = { + students: { + all: ['students'] as const, + list: (filters: { + search?: string; + status?: string; + archived?: boolean; + organizationId?: number; + classId?: number; + teacherId?: number; + }) => ['students', filters] as const, + organizations: (canView: boolean) => ['students', 'organizations', canView] as const, + filterLookups: () => ['students', 'filter-lookups'] as const, + }, + classes: { + all: ['classes'] as const, + list: (filters: { status?: string; type?: string; archived?: boolean }) => + ['classes', filters] as const, + detail: (id: number) => ['classes', 'detail', id] as const, + schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const, + attendanceSummary: (id: number, dateRange: unknown) => + ['classes', 'attendance-summary', id, dateRange] as const, + }, + classSchedules: { + all: ['class-schedules'] as const, + list: (params: { startDate?: string; endDate?: string; classroomIds?: unknown[] }) => + ['class-schedules', params] as const, + }, + classrooms: { + all: ['classrooms'] as const, + list: (archived: boolean) => ['classrooms', archived] as const, + }, + classroomRentals: { + all: ['classroom-rentals'] as const, + list: (month?: string) => ['classroom-rentals', month] as const, + schedule: (year: number, month: number) => + ['classroom-rentals', 'schedule', year, month] as const, + meta: () => ['classroom-rentals', 'meta'] as const, + }, + organizations: { + all: ['organizations'] as const, + list: () => ['organizations'] as const, + options: () => ['organizations', 'options'] as const, + }, + rbac: { + all: ['rbac'] as const, + users: (archived: boolean) => ['rbac', 'users', archived] as const, + allUsers: () => ['rbac', 'users', 'all'] as const, + teachers: (params: { page: number; pageSize: number; search?: string }) => + ['rbac', 'teachers', params] as const, + teacherWorkspace: () => ['rbac', 'teacher-workspace'] as const, + roles: () => ['rbac', 'roles'] as const, + permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const, + permissionsTree: () => ['rbac', 'permissions', 'tree'] as const, + }, + expenses: { + all: ['expenses'] as const, + list: (archived: boolean) => ['expenses', archived ? 'archived' : 'active'] as const, + }, + expenseTypes: { + map: () => ['expense-types', 'map'] as const, + }, + expenseLookups: { + all: ['expense-lookups'] as const, + }, + bills: { + all: ['bills'] as const, + list: (filters: { status?: string; expenseType?: string }) => ['bills', filters] as const, + }, + wallets: { + all: ['wallets'] as const, + list: (params: { keyword?: string; debtOnly?: boolean; roomType?: string }) => + ['wallets', params] as const, + transactions: (studentId: number) => ['wallets', 'transactions', studentId] as const, + roomTypes: () => ['wallets', 'room-types'] as const, + }, + deposits: { + all: ['deposits'] as const, + list: () => ['deposits'] as const, + eligible: (roomType?: string) => ['deposits', 'eligible', roomType] as const, + }, + occupancies: { + all: ['occupancies'] as const, + list: (params: { viewMode?: string; dateRange?: unknown }) => ['occupancies', params] as const, + }, + rooms: { + all: ['rooms'] as const, + overview: (archived: boolean) => ['rooms', 'overview', archived] as const, + visual: (params: { historical: boolean; asOf?: unknown }) => + ['rooms', 'visual', params] as const, + }, + exams: { + all: ['exams'] as const, + detail: (id: number) => ['exams', 'detail', id] as const, + classes: () => ['exams', 'classes'] as const, + }, + operationLogs: { + all: ['operation-logs'] as const, + list: (params: { page: number; pageSize: number; module?: string; dateRange?: unknown }) => + ['operation-logs', params] as const, + }, + attendance: { + all: ['attendance'] as const, + workspace: () => ['attendance', 'workspace'] as const, + syncStatus: () => ['attendance', 'sync-status'] as const, + schedules: (classId: number, date: string) => ['attendance', 'schedules', classId, date] as const, + meta: { + periods: () => ['attendance', 'meta', 'periods'] as const, + classes: () => ['attendance', 'meta', 'classes'] as const, + alerts: () => ['attendance', 'meta', 'alerts'] as const, + }, + }, + attendanceDevices: { + all: ['attendance-devices'] as const, + }, + dashboard: { + all: ['dashboard'] as const, + summary: (period: unknown) => ['dashboard', period] as const, + }, + integration: { + config: () => ['integration', 'config'] as const, + }, + sync: { + jinshujuRules: () => ['sync', 'jinshuju', 'rules'] as const, + }, + archive: { + detail: (studentId: number) => ['archive', studentId] as const, + }, + ai: { + config: () => ['ai', 'config'] as const, + }, +} as const; + +export type QueryKeys = typeof queryKeys; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 7ee66ce..8f08aa5 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -28,6 +28,7 @@ import { message } from '../../ui/app-message'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; +import { queryKeys } from '../../api/queryKeys'; import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas'; import EditableCell from '../EditableCell'; import { usePermission } from '../../hooks/usePermission'; @@ -481,7 +482,7 @@ const StudentProfileContent: React.FC = ({ isError, refetch, } = useQuery({ - queryKey: ['archive', studentId], + queryKey: queryKeys.archive.detail(studentId), queryFn: async () => { return validateResponse( studentProfileAggregateSchema, @@ -492,7 +493,7 @@ const StudentProfileContent: React.FC = ({ const { data: organizations = [] } = useQuery< Array<{ id: number; name: string; isHost?: boolean }> >({ - queryKey: ['organizations', 'options'], + queryKey: queryKeys.organizations.options(), enabled: canLoadOrganizations, queryFn: async () => { try { diff --git a/apps/admin/src/hooks/useApiMutation.ts b/apps/admin/src/hooks/useApiMutation.ts index 3aefd86..141857e 100644 --- a/apps/admin/src/hooks/useApiMutation.ts +++ b/apps/admin/src/hooks/useApiMutation.ts @@ -2,38 +2,62 @@ import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-quer import { message } from '../ui/app-message'; import { getErrorMessage } from '../utils/error'; -interface UseApiMutationOptions { +interface UseApiMutationOptions { /** 成功后自动失效的查询 key(触发列表/详情刷新) */ invalidate?: QueryKey[]; + /** 乐观更新:mutate 前同步改缓存,返回回滚上下文(失败时传给 onError) */ + onMutate?: (vars: TVars) => Promise | TContext | undefined; /** 成功后回调(例如关闭弹窗) */ - onSuccess?: (data: TData, vars: TVars) => void; - /** 失败回调;默认统一用 getErrorMessage 弹错误提示 */ - onError?: (error: unknown) => void; + onSuccess?: (data: TData, vars: TVars, context?: TContext) => void; + /** 失败回调;提供时由调用方负责(含乐观更新回滚),否则默认用 getErrorMessage 弹错误提示 */ + onError?: (error: unknown, vars: TVars, context?: TContext) => void; + /** 结束后回调(无论成败) */ + onSettled?: (data: TData | undefined, error: unknown, vars: TVars, context?: TContext) => void; } /** * useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries, * 消除手写 `await api.xxx(); await fetchData();` 样板。 + * + * 乐观更新示例: + * ```ts + * const mutation = useApiMutation(fn, { + * onMutate: async (vars) => { + * await queryClient.cancelQueries({ queryKey }); + * const previous = queryClient.getQueryData(queryKey); + * queryClient.setQueryData(queryKey, updater); + * return previous; // 回滚上下文 + * }, + * onError: (_e, _v, previous) => queryClient.setQueryData(queryKey, previous), + * }); + * ``` */ -export function useApiMutation( +export function useApiMutation( mutationFn: (vars: TVars) => Promise, - options: UseApiMutationOptions = {}, + options: UseApiMutationOptions = {}, ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn, - onSuccess: (data, vars) => { + // 包装 onMutate:允许调用方返回 undefined(无回滚上下文), + // React Query 的 onMutate 类型要求返回 TContext + onMutate: async (vars) => { + const context = await options.onMutate?.(vars); + return context as TContext; + }, + onSuccess: (data, vars, context) => { for (const key of options.invalidate ?? []) { void queryClient.invalidateQueries({ queryKey: key }); } - options.onSuccess?.(data, vars); + options.onSuccess?.(data, vars, context); }, - onError: (error) => { + onError: (error, vars, context) => { if (options.onError) { - options.onError(error); + options.onError(error, vars, context); } else { message.error(getErrorMessage(error)); } }, + onSettled: options.onSettled, }); } diff --git a/apps/admin/src/hooks/useApiQuery.ts b/apps/admin/src/hooks/useApiQuery.ts new file mode 100644 index 0000000..afddd91 --- /dev/null +++ b/apps/admin/src/hooks/useApiQuery.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import type { QueryKey } from '@tanstack/react-query'; +import type { z } from 'zod'; +import { validateResponse } from '../utils/validate'; + +interface UseApiQueryOptions { + queryKey: QueryKey; + queryFn: () => Promise; + /** zod schema:响应校验失败会抛出带字段路径的错误,由统一错误处理展示 */ + schema: z.ZodType; + enabled?: boolean; + staleTime?: number; + /** 可选的数据转换(React Query select),例如列表原始行 → UI 模型 */ + select?: (data: T) => TSelected; +} + +/** + * useQuery 的类型安全封装:queryFn 返回 unknown, + * 由 zod schema 校验并收敛为 T,消除各页面重复的 + * `validateResponse(schema, await api.get(...))` 样板。 + */ +export function useApiQuery(options: UseApiQueryOptions) { + const { queryKey, queryFn, schema, enabled, staleTime, select } = options; + return useQuery({ + queryKey, + enabled, + staleTime, + queryFn: async () => validateResponse(schema, await queryFn()), + select, + }); +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index ba98017..3e2e692 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from './api/queryClient'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from './App'; import AppErrorBoundary from './components/AppErrorBoundary'; @@ -29,15 +30,6 @@ dayjs.extend(updateLocale); // 必须在所有插件加载后设置 locale dayjs.locale('zh-cn'); -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - staleTime: 30_000, - }, - }, -}); - const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('未找到 #root 挂载点'); diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 5f67898..fb72045 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -1,16 +1,18 @@ // aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd'; import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons'; import api from '../../api'; +import { queryKeys } from '../../api/queryKeys'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { RefreshButton } from '../../components/RefreshButton'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; -import { validateResponse } from '../../utils/validate'; +import { useApiQuery } from '../../hooks/useApiQuery'; +import { getErrorMessage } from '../../utils/error'; import { organizationsSchema } from '../../api/schemas'; import { useDirtyGuard } from '../../hooks/useDirtyGuard'; import { QueryEmpty, QueryErrorState } from '../../components/QueryState'; @@ -60,15 +62,13 @@ const OrganizationsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(); - const { data = [], isLoading, isFetching, isError, refetch } = useQuery({ - queryKey: ['organizations'], - queryFn: async () => - validateResponse( - organizationsSchema, - await api.get('/organizations', { - params: { includeArchived: true }, - }), - ), + const { data = [], isLoading, isFetching, isError, refetch } = useApiQuery({ + queryKey: queryKeys.organizations.list(), + schema: organizationsSchema, + queryFn: () => + api.get('/organizations', { + params: { includeArchived: true }, + }), }); const loading = isLoading || isFetching; @@ -77,23 +77,44 @@ const OrganizationsPage: React.FC = () => { editing ? api.put(`/organizations/${editing.id}`, values) : api.post('/organizations', values), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); const saveCellMutation = useApiMutation( async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) => api.put(`/organizations/${record.id}`, { [field]: value }), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); + const queryClient = useQueryClient(); const statusMutation = useApiMutation( async ({ id, status }: { id: number; status: 'active' | 'archived' }) => status === 'active' ? api.put(`/organizations/${id}`, { status: 'active' }) : api.delete(`/organizations/${id}`), - { invalidate: [['organizations']] }, + { + // 乐观更新:先同步改缓存,失败回滚,最终 invalidate 以服务器为准 + onMutate: async ({ id, status }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.organizations.all }); + const previous = queryClient.getQueryData( + queryKeys.organizations.list(), + ); + queryClient.setQueryData( + queryKeys.organizations.list(), + (old = []) => old.map((item) => (item.id === id ? { ...item, status } : item)), + ); + return previous; + }, + onError: (error, _vars, previous) => { + if (previous) queryClient.setQueryData(queryKeys.organizations.list(), previous); + message.error(getErrorMessage(error)); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.organizations.all }); + }, + }, ); const purgeMutation = useApiMutation( async (id: number) => api.delete(`/organizations/${id}/permanent`), - { invalidate: [['organizations']] }, + { invalidate: [queryKeys.organizations.all] }, ); const filteredData = useMemo(() => { diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 87f36a8..bd94cc5 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -13,11 +13,14 @@ import { NextStepHint } from '../../components/NextStepHint'; import { selectArchiveRecords } from '../archive-view'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; +import { useApiQuery } from '../../hooks/useApiQuery'; import { validateResponse } from '../../utils/validate'; +import { queryKeys } from '../../api/queryKeys'; import { organizationOptionsSchema, organizationsSchema, studentFilterLookupsSchema, + studentProfileAggregateSchema, studentsSchema, } from '../../api/schemas'; import { getErrorMessage } from '../../utils/error'; @@ -100,10 +103,24 @@ const StudentsPage: React.FC = () => { // 导入成功后的「下一步」引导提示 const [nextStepHint, setNextStepHint] = useState<'class' | null>(null); - const openDrawer = useCallback((studentId: number) => { - setDrawerStudentId(studentId); - setDrawerOpen(true); - }, []); + const queryClient = useQueryClient(); + const openDrawer = useCallback( + (studentId: number) => { + // prefetch 学生档案聚合数据:打开抽屉时通常已就绪,减少骨架屏等待 + void queryClient.prefetchQuery({ + queryKey: queryKeys.archive.detail(studentId), + queryFn: async () => + validateResponse( + studentProfileAggregateSchema, + await api.get(`/archive/${studentId}`), + ), + staleTime: 60_000, + }); + setDrawerStudentId(studentId); + setDrawerOpen(true); + }, + [queryClient], + ); const logCreateRef = React.useRef(hasPermission('log:create')); const sensitiveModalRef = React.useRef | null>(null); @@ -161,17 +178,17 @@ const StudentsPage: React.FC = () => { isFetching, isError, refetch, - } = useQuery({ - queryKey: [ - 'students', - searchName, - showArchived, - filterStatus, - effectiveFilterOrganizationId, - filterClassId, - filterTeacherId, - ], - queryFn: async () => { + } = useApiQuery>>({ + queryKey: queryKeys.students.list({ + search: searchName, + status: showArchived ? 'archived' : filterStatus, + archived: showArchived, + organizationId: effectiveFilterOrganizationId, + classId: filterClassId, + teacherId: filterTeacherId, + }), + schema: studentsSchema, + queryFn: () => { const params: Record = { name: searchName || undefined, includeArchived: showArchived ? 'true' : undefined, @@ -181,19 +198,15 @@ const StudentsPage: React.FC = () => { if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId; if (filterClassId) params.classId = filterClassId; if (filterTeacherId) params.teacherId = filterTeacherId; - const res = (await api.get('/students', { params })) as Array>; - return selectArchiveRecords( - validateResponse>>(studentsSchema, res), - showArchived ? 'archived' : 'active', - ); + return api.get('/students', { params }); }, + select: (rows) => selectArchiveRecords(rows, showArchived ? 'archived' : 'active'), }); const loading = isLoading || isFetching; // RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据 - useVisibleRefetch(['students']); + useVisibleRefetch(queryKeys.students.all); - const queryClient = useQueryClient(); - const invalidateStudents: Array = [['students']]; + const invalidateStudents = [queryKeys.students.all]; const saveMutation = useApiMutation( async (values: Record) => editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values), @@ -246,7 +259,7 @@ const StudentsPage: React.FC = () => { const { data: organizations = [] } = useQuery< Array<{ id: number; name: string; isHost?: boolean }> >({ - queryKey: ['students', 'organizations', canViewOrganizations], + queryKey: queryKeys.students.organizations(canViewOrganizations), enabled: canLoadOrganizations, queryFn: async () => { try { @@ -268,7 +281,7 @@ const StudentsPage: React.FC = () => { }, }); const { data: lookups = { classes: [], teachers: [] } } = useQuery({ - queryKey: ['students', 'filter-lookups'], + queryKey: queryKeys.students.filterLookups(), enabled: canLoadOrganizations, queryFn: async () => { try {