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 { 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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
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 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 挂载点');
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user