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:
20
apps/admin/src/api/queryClient.ts
Normal file
20
apps/admin/src/api/queryClient.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
145
apps/admin/src/api/queryKeys.ts
Normal file
145
apps/admin/src/api/queryKeys.ts
Normal 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;
|
||||||
@@ -28,6 +28,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import { queryKeys } from '../../api/queryKeys';
|
||||||
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
|
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
|
||||||
import EditableCell from '../EditableCell';
|
import EditableCell from '../EditableCell';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -481,7 +482,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery<StudentProfileAggregate | null>({
|
} = useQuery<StudentProfileAggregate | null>({
|
||||||
queryKey: ['archive', studentId],
|
queryKey: queryKeys.archive.detail(studentId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return validateResponse<StudentProfileAggregate>(
|
return validateResponse<StudentProfileAggregate>(
|
||||||
studentProfileAggregateSchema,
|
studentProfileAggregateSchema,
|
||||||
@@ -492,7 +493,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
const { data: organizations = [] } = useQuery<
|
const { data: organizations = [] } = useQuery<
|
||||||
Array<{ id: number; name: string; isHost?: boolean }>
|
Array<{ id: number; name: string; isHost?: boolean }>
|
||||||
>({
|
>({
|
||||||
queryKey: ['organizations', 'options'],
|
queryKey: queryKeys.organizations.options(),
|
||||||
enabled: canLoadOrganizations,
|
enabled: canLoadOrganizations,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,38 +2,62 @@ import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-quer
|
|||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
import { getErrorMessage } from '../utils/error';
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
|
||||||
interface UseApiMutationOptions<TData, TVars> {
|
interface UseApiMutationOptions<TData, TVars, TContext> {
|
||||||
/** 成功后自动失效的查询 key(触发列表/详情刷新) */
|
/** 成功后自动失效的查询 key(触发列表/详情刷新) */
|
||||||
invalidate?: QueryKey[];
|
invalidate?: QueryKey[];
|
||||||
|
/** 乐观更新:mutate 前同步改缓存,返回回滚上下文(失败时传给 onError) */
|
||||||
|
onMutate?: (vars: TVars) => Promise<TContext | undefined> | TContext | undefined;
|
||||||
/** 成功后回调(例如关闭弹窗) */
|
/** 成功后回调(例如关闭弹窗) */
|
||||||
onSuccess?: (data: TData, vars: TVars) => void;
|
onSuccess?: (data: TData, vars: TVars, context?: TContext) => void;
|
||||||
/** 失败回调;默认统一用 getErrorMessage 弹错误提示 */
|
/** 失败回调;提供时由调用方负责(含乐观更新回滚),否则默认用 getErrorMessage 弹错误提示 */
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown, vars: TVars, context?: TContext) => void;
|
||||||
|
/** 结束后回调(无论成败) */
|
||||||
|
onSettled?: (data: TData | undefined, error: unknown, vars: TVars, context?: TContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries,
|
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries,
|
||||||
* 消除手写 `await api.xxx(); await fetchData();` 样板。
|
* 消除手写 `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>,
|
mutationFn: (vars: TVars) => Promise<TData>,
|
||||||
options: UseApiMutationOptions<TData, TVars> = {},
|
options: UseApiMutationOptions<TData, TVars, TContext> = {},
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<TData, Error, TVars>({
|
return useMutation<TData, Error, TVars, TContext>({
|
||||||
mutationFn,
|
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 ?? []) {
|
for (const key of options.invalidate ?? []) {
|
||||||
void queryClient.invalidateQueries({ queryKey: key });
|
void queryClient.invalidateQueries({ queryKey: key });
|
||||||
}
|
}
|
||||||
options.onSuccess?.(data, vars);
|
options.onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error, vars, context) => {
|
||||||
if (options.onError) {
|
if (options.onError) {
|
||||||
options.onError(error);
|
options.onError(error, vars, context);
|
||||||
} else {
|
} else {
|
||||||
message.error(getErrorMessage(error));
|
message.error(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSettled: options.onSettled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/admin/src/hooks/useApiQuery.ts
Normal file
31
apps/admin/src/hooks/useApiQuery.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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 { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import AppErrorBoundary from './components/AppErrorBoundary';
|
import AppErrorBoundary from './components/AppErrorBoundary';
|
||||||
@@ -29,15 +30,6 @@ dayjs.extend(updateLocale);
|
|||||||
// 必须在所有插件加载后设置 locale
|
// 必须在所有插件加载后设置 locale
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
staleTime: 30_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) throw new Error('未找到 #root 挂载点');
|
if (!rootElement) throw new Error('未找到 #root 挂载点');
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||||
import React, { useMemo, useState } from 'react';
|
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 { App, Alert, Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||||
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
import { queryKeys } from '../../api/queryKeys';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { RefreshButton } from '../../components/RefreshButton';
|
import { RefreshButton } from '../../components/RefreshButton';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
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 { organizationsSchema } from '../../api/schemas';
|
||||||
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
||||||
import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
|
import { QueryEmpty, QueryErrorState } from '../../components/QueryState';
|
||||||
@@ -60,15 +62,13 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>();
|
const [filterStatus, setFilterStatus] = useState<string>();
|
||||||
|
|
||||||
const { data = [], isLoading, isFetching, isError, refetch } = useQuery({
|
const { data = [], isLoading, isFetching, isError, refetch } = useApiQuery<OrganizationItem[]>({
|
||||||
queryKey: ['organizations'],
|
queryKey: queryKeys.organizations.list(),
|
||||||
queryFn: async () =>
|
schema: organizationsSchema,
|
||||||
validateResponse<OrganizationItem[]>(
|
queryFn: () =>
|
||||||
organizationsSchema,
|
api.get<OrganizationItem[]>('/organizations', {
|
||||||
await api.get<OrganizationItem[]>('/organizations', {
|
|
||||||
params: { includeArchived: true },
|
params: { includeArchived: true },
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
|
|
||||||
@@ -77,23 +77,44 @@ const OrganizationsPage: React.FC = () => {
|
|||||||
editing
|
editing
|
||||||
? api.put(`/organizations/${editing.id}`, values)
|
? api.put(`/organizations/${editing.id}`, values)
|
||||||
: api.post('/organizations', values),
|
: api.post('/organizations', values),
|
||||||
{ invalidate: [['organizations']] },
|
{ invalidate: [queryKeys.organizations.all] },
|
||||||
);
|
);
|
||||||
const saveCellMutation = useApiMutation(
|
const saveCellMutation = useApiMutation(
|
||||||
async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) =>
|
async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) =>
|
||||||
api.put(`/organizations/${record.id}`, { [field]: value }),
|
api.put(`/organizations/${record.id}`, { [field]: value }),
|
||||||
{ invalidate: [['organizations']] },
|
{ invalidate: [queryKeys.organizations.all] },
|
||||||
);
|
);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const statusMutation = useApiMutation(
|
const statusMutation = useApiMutation(
|
||||||
async ({ id, status }: { id: number; status: 'active' | 'archived' }) =>
|
async ({ id, status }: { id: number; status: 'active' | 'archived' }) =>
|
||||||
status === 'active'
|
status === 'active'
|
||||||
? api.put(`/organizations/${id}`, { status: 'active' })
|
? api.put(`/organizations/${id}`, { status: 'active' })
|
||||||
: api.delete(`/organizations/${id}`),
|
: 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(
|
const purgeMutation = useApiMutation(
|
||||||
async (id: number) => api.delete(`/organizations/${id}/permanent`),
|
async (id: number) => api.delete(`/organizations/${id}/permanent`),
|
||||||
{ invalidate: [['organizations']] },
|
{ invalidate: [queryKeys.organizations.all] },
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import { NextStepHint } from '../../components/NextStepHint';
|
|||||||
import { selectArchiveRecords } from '../archive-view';
|
import { selectArchiveRecords } from '../archive-view';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||||
|
import { useApiQuery } from '../../hooks/useApiQuery';
|
||||||
import { validateResponse } from '../../utils/validate';
|
import { validateResponse } from '../../utils/validate';
|
||||||
|
import { queryKeys } from '../../api/queryKeys';
|
||||||
import {
|
import {
|
||||||
organizationOptionsSchema,
|
organizationOptionsSchema,
|
||||||
organizationsSchema,
|
organizationsSchema,
|
||||||
studentFilterLookupsSchema,
|
studentFilterLookupsSchema,
|
||||||
|
studentProfileAggregateSchema,
|
||||||
studentsSchema,
|
studentsSchema,
|
||||||
} from '../../api/schemas';
|
} from '../../api/schemas';
|
||||||
import { getErrorMessage } from '../../utils/error';
|
import { getErrorMessage } from '../../utils/error';
|
||||||
@@ -100,10 +103,24 @@ const StudentsPage: React.FC = () => {
|
|||||||
// 导入成功后的「下一步」引导提示
|
// 导入成功后的「下一步」引导提示
|
||||||
const [nextStepHint, setNextStepHint] = useState<'class' | null>(null);
|
const [nextStepHint, setNextStepHint] = useState<'class' | null>(null);
|
||||||
|
|
||||||
const openDrawer = useCallback((studentId: number) => {
|
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);
|
setDrawerStudentId(studentId);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}, []);
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
const logCreateRef = React.useRef(hasPermission('log:create'));
|
const logCreateRef = React.useRef(hasPermission('log:create'));
|
||||||
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
|
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
|
||||||
@@ -161,17 +178,17 @@ const StudentsPage: React.FC = () => {
|
|||||||
isFetching,
|
isFetching,
|
||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery<any[]>({
|
} = useApiQuery<Array<Record<string, unknown>>>({
|
||||||
queryKey: [
|
queryKey: queryKeys.students.list({
|
||||||
'students',
|
search: searchName,
|
||||||
searchName,
|
status: showArchived ? 'archived' : filterStatus,
|
||||||
showArchived,
|
archived: showArchived,
|
||||||
filterStatus,
|
organizationId: effectiveFilterOrganizationId,
|
||||||
effectiveFilterOrganizationId,
|
classId: filterClassId,
|
||||||
filterClassId,
|
teacherId: filterTeacherId,
|
||||||
filterTeacherId,
|
}),
|
||||||
],
|
schema: studentsSchema,
|
||||||
queryFn: async () => {
|
queryFn: () => {
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
name: searchName || undefined,
|
name: searchName || undefined,
|
||||||
includeArchived: showArchived ? 'true' : undefined,
|
includeArchived: showArchived ? 'true' : undefined,
|
||||||
@@ -181,19 +198,15 @@ const StudentsPage: React.FC = () => {
|
|||||||
if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId;
|
if (effectiveFilterOrganizationId) params.organizationId = effectiveFilterOrganizationId;
|
||||||
if (filterClassId) params.classId = filterClassId;
|
if (filterClassId) params.classId = filterClassId;
|
||||||
if (filterTeacherId) params.teacherId = filterTeacherId;
|
if (filterTeacherId) params.teacherId = filterTeacherId;
|
||||||
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
|
return api.get('/students', { params });
|
||||||
return selectArchiveRecords(
|
|
||||||
validateResponse<Array<Record<string, unknown>>>(studentsSchema, res),
|
|
||||||
showArchived ? 'archived' : 'active',
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
select: (rows) => selectArchiveRecords(rows, showArchived ? 'archived' : 'active'),
|
||||||
});
|
});
|
||||||
const loading = isLoading || isFetching;
|
const loading = isLoading || isFetching;
|
||||||
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
// RouteKeeper 保活页面切回时刷新列表,避免看到陈旧数据
|
||||||
useVisibleRefetch(['students']);
|
useVisibleRefetch(queryKeys.students.all);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const invalidateStudents = [queryKeys.students.all];
|
||||||
const invalidateStudents: Array<readonly unknown[]> = [['students']];
|
|
||||||
const saveMutation = useApiMutation(
|
const saveMutation = useApiMutation(
|
||||||
async (values: Record<string, unknown>) =>
|
async (values: Record<string, unknown>) =>
|
||||||
editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values),
|
editing ? api.put(`/students/${editing.id}`, values) : api.post('/students', values),
|
||||||
@@ -246,7 +259,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
const { data: organizations = [] } = useQuery<
|
const { data: organizations = [] } = useQuery<
|
||||||
Array<{ id: number; name: string; isHost?: boolean }>
|
Array<{ id: number; name: string; isHost?: boolean }>
|
||||||
>({
|
>({
|
||||||
queryKey: ['students', 'organizations', canViewOrganizations],
|
queryKey: queryKeys.students.organizations(canViewOrganizations),
|
||||||
enabled: canLoadOrganizations,
|
enabled: canLoadOrganizations,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -268,7 +281,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { data: lookups = { classes: [], teachers: [] } } = useQuery<StudentFilterLookups>({
|
const { data: lookups = { classes: [], teachers: [] } } = useQuery<StudentFilterLookups>({
|
||||||
queryKey: ['students', 'filter-lookups'],
|
queryKey: queryKeys.students.filterLookups(),
|
||||||
enabled: canLoadOrganizations,
|
enabled: canLoadOrganizations,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user