数据获取与服务端状态
前端所有可缓存服务端数据(列表、详情、下拉源、统计等)统一由 TanStack Query v5 管理。utils/request.ts 只负责传输层能力(token 注入、401 刷新、统一错误提示、二进制读取等),页面不手写 loading / data state 与 fetchXxx 拉取函数。
缓存一致性契约不在本页
「mutation 该失效哪些 key、key 树怎么设计、手写 mutation 什么时候不能回填详情、失效行为怎么测」这类可机械核对的规则,统一维护在 query-cache.md → 缓存一致性契约,硬性约束条目在 constraints-frontend.md → 缓存与 query key。
本页只讲分层结构、基建 API 与页面写法,避免同一规则多处维护。
分层结构
packages/web/src/
├── lib/query.ts # queryClient 单例 + unwrap + toQueryString + compactQuery + LOOKUP_STALE_TIME + createLimiter
├── lib/contract-query.ts # 契约调用层:api / useApiQuery / useApiMutation / useSaveMutation / createResourceQueries
├── lib/api-conformance.test.ts # 字面量 URL 对照服务端路由快照
├── hooks/useListPage.ts # 标准分页列表页:useListSearch + useFilterQuery + 域 useList + listTableProps 一次接好
├── hooks/useListSearch.ts # 列表搜索状态:draft/submitted + 分页 + 查询必回源(非标准页直接使用)
├── hooks/useFilterQuery.ts # 已提交筛选 → 契约查询参数:compactParams 后按内容稳定引用
├── hooks/useEditModal.ts # 新增/编辑弹窗编排:校验/提交/提示/关闭/表单重挂载(壳由 components/EditFormModal 承接)
├── hooks/queries/ # 后台域 hooks:每个业务域一个文件
├── utils/request.ts # 后台 request 实例
└── member/
├── lib/member-query.ts # 会员端独立 QueryClient
├── hooks/queries.ts # 会员端域 hooks
└── utils/member-request.ts后台 QueryClientProvider 在 main.tsx 顶层挂载并包裹 AuthProvider;开发模式在 App.tsx 挂载 React Query Devtools。会员端在 member/App-member.tsx 挂载独立的 memberQueryClient 与 MemberAuthProvider。
退出登录、切换账号或添加账号成功时,AuthProvider 清空身份相关的查询与 mutation 缓存,仅保留 auth-public 公开配置查询,避免跨账号数据泄漏。认证会话 ['auth', 'me'] 与导航菜单树 ['menus', 'user-tree'] 本身也走 Query 体系;权限变化由 invalidateCurrentUserAccess(qc) 统一失效这两类数据。
基建(lib/query.ts)
| 导出 | 说明 |
|---|---|
queryClient | 后台全局单例。默认 staleTime: 30s、retry: false、refetchOnWindowFocus: false;mutation 默认不重试 |
unwrap(res) | 解包统一响应:code !== 0 时抛 ApiError |
ApiError | 携带业务 code 的错误类型,mutateAsync 抛出后可保持弹窗打开 |
toQueryString(params) | 构建查询串,过滤 undefined / null / 空字符串,非空时带 ? 前缀 |
compactParams(params) | 过滤 undefined / null / 空串、保留 0 / false,保留键类型;页面内经 useListPage({ toQuery }) 或 useFilterQuery({ … }) 使用(同内容同引用),纯函数形态只用于 hook 之外的导出 / 深链工具函数 |
compactQuery(params) | compactParams 的弱类型形态(Record<string, T>),用于拼查询串或透传非契约通道 |
LOOKUP_STALE_TIME | 5 分钟,用于字典、部门树、用户下拉源等低频 lookup |
createLimiter(max) | 轻量并发信号量,限制同类请求并发数 |
基建(lib/contract-query.ts)
所有服务端调用由 @zenith/shared/{域} 的契约操作驱动:URL、方法、参数与响应类型来自契约,域 hooks 与页面不书写 /api/... 字面量。
| 导出 | 说明 |
|---|---|
api(op, input?, options?) | 单次调用,返回解包后的 data;input 为契约输入 { params?, query?, headers?, body? }(headers 段仅当契约声明了业务请求头时存在,自动并入请求头,不参与 query key),options 为请求选项(silent、client 等) |
apiRaw(op, input?, options?) | 同 api,但返回完整响应信封 { code, message, data } 且不抛业务错误:供需要 message(结果文案)、非零 code 分支或额外信封字段(限流倒计时)的调用使用,如登录 / 注册 / 验证码、解封等 |
urlOf(op, { params?, query? }) | 契约操作 + URL 相关输入段 → 完整 URL;带 body 的操作也只需 params / query(request.postForm(urlOf(op), formData)、<Upload action>、下载链接) |
contractKey(op, input?) | 查询 key:[资源键, 操作名, input];省略 input 得到该操作的公共前缀(invalidateQueries / useListSearch({ listKey }));传部分输入(如只有 params)得到该输入子集的前缀。createResourceQueries 的 keys 也由它生成 |
apiQueryOptions(op, input?, options?) / useApiQuery(op, input?, options?) | 可缓存查询;options 透传 TanStack Query 选项(enabled、staleTime、placeholderData、refetchInterval…),select 可改变返回数据形状(缓存与 key 仍是契约响应) |
useApiMutation(op, { invalidate, requestOptions, ...mutationOptions }) | 变更;变量即契约输入,invalidate(qc, output, variables) 负责失效。第二个类型参数 TVariables extends InputOf<Op> 允许变量携带失效上下文(如被移动节点的原目录),额外字段只传给 invalidate、请求前剥离 |
useSaveMutation(createOp, updateOp, { invalidate, requestOptions, ...mutationOptions }) | 非标准命名的新增 / 编辑对:变量 { id?, values },无 id 走 createOp、有 id 走 updateOp(路径参数 id);values 接受 create / update 入参的部分形态,invalidate(qc, saved, vars) 负责失效 |
createResourceQueries(contract, options?) | 标准 CRUD 契约组的 keys 与 useList / useDetail / useSave / useDelete / useLookup |
createResourceQueries 依赖契约操作命名:list(分页)必填;detail / create / update / remove / removeBatch(多条删除走 /batch)/ all(下拉源)均可选,声明了才提供对应 hook(未声明 detail 时不提供 useDetail,实体类型取列表项)。主键类型依次取 detail / update / remove 的路径参数 id(idParam → number,z.object({ id: z.string() }) → string),useDetail / useSave / useDelete / keys.detail 随之推导。 keys.lists / list(params) / detail(id) / lookup 就是对应操作的 contractKey(keys.all 为资源根 [resourceKeyOf(basePath)]),工厂之外按契约操作预取 / 失效同一缓存无需换 key。
| 选项 | 说明 |
|---|---|
onSaved / onDeleted | 保存 / 删除成功后的跨域联动失效 |
listStaleTime | 覆盖列表查询 staleTime |
requestOptions | 请求选项,如 { client: memberRequest } 切换到会员端请求实例 |
工厂固定失效契约:保存后失效 detail(saved.id)、lists 与(契约声明 all 时)lookup;删除后 removeQueries(detail(id)),再失效 lists 与 lookup。
import { xxxContract } from '@zenith/shared/{域}';
import { createResourceQueries, useApiMutation } from '@/lib/contract-query';
export const {
keys: xxxKeys,
useList: useXxxList,
useDetail: useXxxDetail,
useSave: useSaveXxx,
useDelete: useDeleteXxxs,
useLookup: useAllXxxs,
} = createResourceQueries(xxxContract);
/** 其余操作:变量即契约输入 */
export const useAssignXxxMenus = () =>
useApiMutation(xxxContract.assignMenus, {
invalidate: (qc, _output, { params }) => void qc.invalidateQueries({ queryKey: xxxKeys.detail(params.id) }),
});仍以字面量书写 URL 的调用由 lib/api-conformance.test.ts 对照服务端路由快照校验,服务端尚无端点的调用登记在 lib/api-conformance.allowlist.ts。
域 hooks 约定
- 每个业务域一个文件,命名与 API 资源一致(如
users.ts、payment-orders.ts) params只放可序列化值;Date先转成 API 字符串,空字符串筛选项映射为undefined- 下拉源复用所有者域 hook,例如
useAllUsers、useFlatDepartments、useDepartmentTree、useMenuTree、useAllRoles、useAllPositions、useDictItems queryFn引用的、会影响响应数据的变量必须进入queryKey- 官方 ESLint 插件
@tanstack/eslint-plugin-query已启用;多查询聚合使用useQueries的combine,且combine必须是useCallback记忆化的函数并返回普通对象 / 数组:TanStack 只在combine引用或底层结果变化时重跑它,并用replaceEqualDeep对普通对象做结构共享;内联函数每次渲染都重跑,Map/Set不参与共享,聚合结果的引用就每次都变,下游一切 memo 随之失效(参考hooks/queries/report-designer.ts) - 成功提示由页面编排层负责;错误提示默认由 request 层处理,
silent调用需自行补提示
列表页模式
标准分页列表页统一使用 useListPage 契约模式:筛选状态类型 = 契约 list 操作的 query 去掉分页键,listKey 由 contractKey(contract.list) 派生(与 createResourceQueries 的 keys.lists 同键),defaults / toQuery 不再由页面书写。 它组合 useListSearch(分页状态、输入草稿态、已提交查询态、「查询 / 重置必须回源」的失效逻辑)、 useFilterQuery(已提交筛选 → 契约查询参数)、契约派生的域 useList 与 listTableProps,一次返回搜索绑定、filterQuery、 listQuery、表格 tableProps 与工具栏所需的 toolbarProps / filterSchema / bindRange。
const page = useListPage({
contract: xxxContract,
useList: useXxxList, // createResourceQueries 派生的域 hook,模块级稳定
// defaults: { status: 'enabled' }, // 初始筛选(重置回到这里)
// params: { siteId }, // 契约必填的作用域参数:原样传给 useList,不经 compact
// enabled: siteId !== undefined,
// table: { rowSelection, empty: '暂无数据' },
});
// 筛选控件由契约 query 的 x-filter 语义派生:keyword → 搜索框(占位 = 「搜索 + 匹配字段」),enum → 下拉(标签取契约声明的字典 / options),
// bool → 是 / 否,id → 数字,成对时间端点 → 范围选择器;专用控件走 overrides,契约之外的手写控件走 extraFilters
<ListSearchToolbar page={page} filters={['keyword', 'status', ['startTime', 'endTime']]} create={…} actions={…} />
<ConfigurableTable<Xxx> columns={columns} {...page.tableProps} />
<ExportButton entity="system.xxxs" query={page.filterQuery} permission="system:xxx:export" />搜索状态不是契约 query 形状(客户端派生条件、字段改名、日期只到天)时用映射模式:defaults + listKey: xxxKeys.lists + toQuery: (s) => ({ … })(结果即 filterQuery),工具栏用槽位写法 keyword={<KeywordInput {...bindKeyword('keyword')} />} / filters={…} / onSearch / onReset。
bind(key) / bindKeyword(key) / bindRange([start, end]) 是受控筛选控件的标准绑定;控件回传类型比字段宽时传 parse 收窄: bind('status', (v) => enumValueOf(XXX_STATUSES, v))。 Checkbox 等非 value / onChange 形态的控件才用 setField(key)(如 (e) => setField('archived')(!!e.target.checked)),一次改多个字段才用 setDraftParams。 applySearch(params) 用于点击部门树、标签、收藏开关、保存视图等不经过输入框的筛选;它同步更新 draft 与 submitted,回到第一页并失效列表。不要暴露 submittedParams 的裸 setter。
树形 / 不分页 / 客户端过滤、一页多列表、列表 hook 第二实参不是 enabled 等非标准形态退一层直接用 useListSearch + useFilterQuery, 表格用 listTableProps(listQuery, { pagination: buildPagination }):
const { page, pageSize, buildPagination, bind, bindKeyword, submittedParams, handleSearch, handleReset }
= useListSearch<SearchParams>({ defaults: defaultSearchParams, listKey: xxxKeys.lists });
const filterQuery = useFilterQuery({ keyword: submittedParams.keyword, status: enumValueOf(XXX_STATUSES, submittedParams.status) });
const listQuery = useXxxList({ page, pageSize, ...filterQuery }, { refetchInterval: hasActive ? 3000 : false });
<ConfigurableTable<Xxx> columns={columns} {...listTableProps(listQuery, { pagination: buildPagination })} />弹窗 / 抽屉懒加载
新增/编辑弹窗统一用 useEditModal。它负责打开状态、详情懒加载、表单校验、保存、成功提示、关闭与表单重挂载。
const modal = useEditModal<Xxx, Partial<CreateXxxInput>>({
entityName: '示例',
save: useSaveXxx(),
useDetail: useXxxDetail,
defaults: { status: 'enabled' },
});
// EditFormModal = AppModal({...modal.modalProps}) > Spin(detailLoading) > Form(key=formKey, formProps);
// title / okText / width 作属性覆盖,Form 之前的说明传 header,Form 额外属性传 formProps;抽屉形态用 EditFormSheet
<EditFormModal modal={modal} width={660}>…字段…</EditFormModal>注意:React 的 key 不能通过 spread 传入,必须显式写在 <Form key={modal.formKey} ...> 上。少数自持表单实例的页面可直接复用 formRemountKey(id, detail)。
beforeSave 用于跨字段校验或载荷转换;需要中断提交时先给用户提示,再调用 abortSubmit()。successMessage 返回 null 可关闭默认成功提示。
轮询、上传与下载
- 轮询:用 Query 的
refetchInterval;条件轮询使用函数形式refetchInterval: (query) => hasRunning(query.state.data) ? 5000 : false - 上传进度:
request.postForm(url, formData, { onProgress })包进mutationFn,变量形如{ formData, onProgress } - 二进制读取 / 下载:后台使用
request.getBlob/request.download;文件预览使用fetchManagedFileBlob - 流式 / SSE:
request.fetchRaw拿原生 Response,再经utils/streaming.ts的streamText/readSseStream读取 - 导出:列表导出优先使用
ExportButton,由useExportJobRunner接入导出任务
会员端(member SPA)
src/member/ 是独立入口,使用 memberQueryClient 与 memberRequest。域 hooks 集中在 member/hooks/queries.ts:会员端契约(memberAuthContract / memberSelfContract / memberRenewalContract)经 apiQueryOptions / useApiMutation 驱动,并统一通过 requestOptions: { client: memberRequest } 指定会员请求实例,不得混用后台 request。登录 / 注册 / 验证码等需要直接消费响应包络(code / message)的调用用 apiRaw(op, input, { client: memberRequest })。会员端移动列表可使用 useInfiniteQuery 实现加载更多。
不走 TanStack Query 的场景
以下数据流保持命令式或本地状态:
- WebSocket / SSE / 流式:聊天消息流、进程 SSE、xterm 终端、Docker 日志、AI 流式回复
- 一次性动作:文件下载、验密、网络诊断类单发操作
- 与命令式组件深度耦合的数据流:如 db-admin 的
useTableRowsInfinite - 本地优先且单属主的偏好系统:
hooks/PreferencesProvider.tsx - 认证前置流程:登录、重置密码、OAuth / 企业回调、OAuth2 授权页
只要是可缓存的读或会影响其他视图的写,就应进入域 hook。