feat(admin): 深化 TanStack Query 数据层

- 新增 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 可命中缓存
This commit is contained in:
2026-08-08 18:37:55 +08:00
parent 4dd7b5a0b1
commit 9565a0f23c
8 changed files with 310 additions and 63 deletions

View File

@@ -0,0 +1,20 @@
import { QueryClient } from '@tanstack/react-query';
/**
* 全局 QueryClient统一缓存/重试策略。
* - retry 1接口失败最多重试 1 次,避免瞬时错误直接白屏
* - staleTime 30s30 秒内重复请求走缓存
* - gcTime 5min不活跃缓存 5 分钟后回收
* - refetchOnWindowFocus false切回窗口不自动全量刷新
* 保活页面由 useVisibleRefetch 按需刷新,避免重复请求
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
},
});

View File

@@ -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;

View File

@@ -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<StudentProfileContentProps> = ({
isError,
refetch,
} = useQuery<StudentProfileAggregate | null>({
queryKey: ['archive', studentId],
queryKey: queryKeys.archive.detail(studentId),
queryFn: async () => {
return validateResponse<StudentProfileAggregate>(
studentProfileAggregateSchema,
@@ -492,7 +493,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const { data: organizations = [] } = useQuery<
Array<{ id: number; name: string; isHost?: boolean }>
>({
queryKey: ['organizations', 'options'],
queryKey: queryKeys.organizations.options(),
enabled: canLoadOrganizations,
queryFn: async () => {
try {

View File

@@ -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<TData, TVars> {
interface UseApiMutationOptions<TData, TVars, TContext> {
/** 成功后自动失效的查询 key触发列表/详情刷新) */
invalidate?: QueryKey[];
/** 乐观更新mutate 前同步改缓存,返回回滚上下文(失败时传给 onError */
onMutate?: (vars: TVars) => Promise<TContext | undefined> | 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<TData = unknown, TVars = void>(
export function useApiMutation<TData = unknown, TVars = void, TContext = unknown>(
mutationFn: (vars: TVars) => Promise<TData>,
options: UseApiMutationOptions<TData, TVars> = {},
options: UseApiMutationOptions<TData, TVars, TContext> = {},
) {
const queryClient = useQueryClient();
return useMutation<TData, Error, TVars>({
return useMutation<TData, Error, TVars, TContext>({
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,
});
}

View File

@@ -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<T, TSelected = T> {
queryKey: QueryKey;
queryFn: () => Promise<unknown>;
/** zod schema响应校验失败会抛出带字段路径的错误由统一错误处理展示 */
schema: z.ZodType<unknown>;
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<T, TSelected = T>(options: UseApiQueryOptions<T, TSelected>) {
const { queryKey, queryFn, schema, enabled, staleTime, select } = options;
return useQuery<T, Error, TSelected>({
queryKey,
enabled,
staleTime,
queryFn: async () => validateResponse<T>(schema, await queryFn()),
select,
});
}

View File

@@ -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 挂载点');

View File

@@ -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<string>();
const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
queryKey: ['organizations'],
queryFn: async () =>
validateResponse<OrganizationItem[]>(
organizationsSchema,
await api.get<OrganizationItem[]>('/organizations', {
params: { includeArchived: true },
}),
),
const { data = [], isLoading, isFetching, isError, refetch } = useApiQuery<OrganizationItem[]>({
queryKey: queryKeys.organizations.list(),
schema: organizationsSchema,
queryFn: () =>
api.get<OrganizationItem[]>('/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<OrganizationItem[]>(
queryKeys.organizations.list(),
);
queryClient.setQueryData<OrganizationItem[]>(
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(() => {

View File

@@ -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<ReturnType<typeof modal.confirm> | null>(null);
@@ -161,17 +178,17 @@ const StudentsPage: React.FC = () => {
isFetching,
isError,
refetch,
} = useQuery<any[]>({
queryKey: [
'students',
searchName,
showArchived,
filterStatus,
effectiveFilterOrganizationId,
filterClassId,
filterTeacherId,
],
queryFn: async () => {
} = useApiQuery<Array<Record<string, unknown>>>({
queryKey: queryKeys.students.list({
search: searchName,
status: showArchived ? 'archived' : filterStatus,
archived: showArchived,
organizationId: effectiveFilterOrganizationId,
classId: filterClassId,
teacherId: filterTeacherId,
}),
schema: studentsSchema,
queryFn: () => {
const params: Record<string, unknown> = {
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<Record<string, unknown>>;
return selectArchiveRecords(
validateResponse<Array<Record<string, unknown>>>(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<readonly unknown[]> = [['students']];
const invalidateStudents = [queryKeys.students.all];
const saveMutation = useApiMutation(
async (values: Record<string, unknown>) =>
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<StudentFilterLookups>({
queryKey: ['students', 'filter-lookups'],
queryKey: queryKeys.students.filterLookups(),
enabled: canLoadOrganizations,
queryFn: async () => {
try {