refactor(hooks): useActionQuery/useActionMutation 接入 QueryClient + 向后兼容

- useActionQuery 新增 queryKey 入参模式:传入 queryKey 走 useQuery 跨页共享缓存;不传回退旧 useEffect + useState 模式。Hook 内部始终声明 useQuery/useState/useEffect 以遵守 React Hooks 规则,通过 isCacheMode 切换启用状态

- useActionMutation 新增 mutationFn + actionId 模式:成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries;mutate(action?) 参数可选

- toast 替换为 notify(行为等价,便于未来替换 toast 库)

- 同步更新 004/005 架构文档签名说明
This commit is contained in:
SpecialX
2026-07-05 18:15:58 +08:00
parent 0f9d8825e7
commit dd7a49504f
4 changed files with 235 additions and 54 deletions

View File

@@ -691,8 +691,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| Hook | 文件 | 签名 | 用途 | 消费方 | | Hook | 文件 | 签名 | 用途 | 消费方 |
|------|------|------|------|--------| |------|------|------|------|--------|
| `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation<T>(options?): { isWorking, mutate }` | 通用 Server Action mutation Hook替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式 | 1 个示范P1-4: schools-view潜在影响 50+ 文件 | | `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation<T>(options?): { isWorking, mutate }`。Options 联合类型:旧版 `{ successMessage?, errorMessage?, onSuccess?, onError? }` 或新版 `{ mutationFn?, actionId?, params?, invalidateQueryKeys?, ... }`。新版接入 QueryClient成功后按 `CLIENT_INVALIDATION_MAP[actionId]` 自动 invalidateQueries。`mutate(action?)` 参数可选:传 action 走旧模式,不传则使用 options.mutationFn | 通用 Server Action mutation Hook替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式。V5 缓存策略重构新增 QueryClient + actionId 自动失效,向后兼容旧调用方 | 1 个示范P1-4: schools-view潜在影响 50+ 文件 |
| `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery<T>(action, options?): { data, loading, error, refetch }` | 通用 Server Action 查询 Hook替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护 | 1 个示范P1-4: create-question-dialog潜在影响 11 个文件 | | `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery<T>(action, options?): { data, loading, error, refetch }`。Options 联合类型:旧版 `{ deps?, enabled?, errorMessage? }` 或新版 `UseActionQueryWithCacheOptions`(含 `queryKey` + `UseQueryOptions`)。传入 `queryKey` 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则 | 通用 Server Action 查询 Hook替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护。V5 缓存策略重构新增 queryKey 入参走 QueryClient向后兼容旧调用方 | 1 个示范P1-4: create-question-dialog潜在影响 11 个文件 |
**V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更): **V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更):

View File

@@ -1495,8 +1495,8 @@
{ {
"name": "useActionMutation", "name": "useActionMutation",
"file": "hooks/use-action-mutation.ts", "file": "hooks/use-action-mutation.ts",
"signature": "useActionMutation<T>(options?: { successMessage?, errorMessage?, onSuccess?, onError? }): { isWorking: boolean; mutate: (action: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined> }", "signature": "useActionMutation<T>(options?: UseActionMutationOptions<T>): { isWorking: boolean; mutate: (action?: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined> }。Options 为联合类型:旧版 { successMessage?, errorMessage?, onSuccess?, onError? } 或新版 { mutationFn?, actionId?, params?, invalidateQueryKeys?, successMessage?, errorMessage?, onSuccess?, onError? }。新版接入 QueryClient成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries。mutate 参数可选:传 action 走旧模式,不传则使用 options.mutationFn",
"purpose": "通用 Server Action mutation HookP1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。支持 successMessage/errorMessage/onSuccess/onError 配置", "purpose": "通用 Server Action mutation HookP1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。V5 缓存策略重构:接入 QueryClient + actionId 自动 invalidate向后兼容旧调用方mutate(action) 仍可用)",
"usedBy": [ "usedBy": [
"school/components/school-form-dialog.tsx", "school/components/school-form-dialog.tsx",
"school/components/school-delete-dialog.tsx" "school/components/school-delete-dialog.tsx"
@@ -1505,8 +1505,8 @@
{ {
"name": "useActionQuery", "name": "useActionQuery",
"file": "hooks/use-action-query.ts", "file": "hooks/use-action-query.ts",
"signature": "useActionQuery<T>(action: () => Promise<ActionState<T>>, options?: { deps?, enabled?, errorMessage? }): { data, loading, error, refetch }", "signature": "useActionQuery<T>(action: () => Promise<ActionState<T>>, options?: UseActionQueryOptions<T>): { data, loading, error, refetch }。Options 为联合类型:旧版 { deps?, enabled?, errorMessage? } 或新版 UseActionQueryWithCacheOptions含 queryKey + UseQueryOptions。传入 queryKey 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则",
"purpose": "通用 Server Action 查询 HookP1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。内置竞态防护cancelled flag", "purpose": "通用 Server Action 查询 HookP1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。V5 缓存策略重构:新增 queryKey 入参走 QueryClient向后兼容旧调用方。内置竞态防护requestId ref",
"usedBy": [ "usedBy": [
"questions/components/create-question-dialog.tsx" "questions/components/create-question-dialog.tsx"
] ]

View File

@@ -1,10 +1,13 @@
"use client" "use client"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import { toast } from "sonner" import { useQueryClient } from "@tanstack/react-query"
import { notify } from "@/shared/lib/notify"
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
export interface UseActionMutationOptions<T> { /** 旧版选项(向后兼容,无 mutationFn / actionId */
export interface UseActionMutationLegacyOptions<T> {
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ /** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
successMessage?: string | false successMessage?: string | false
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ /** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
@@ -15,51 +18,146 @@ export interface UseActionMutationOptions<T> {
onError?: (error: unknown) => void onError?: (error: unknown) => void
} }
/** 新版选项(含 mutationFn + actionId 自动 invalidate */
export interface UseActionMutationWithCacheOptions<T> {
/** mutation 函数(新模式必填) */
mutationFn?: () => Promise<ActionState<T>>
/** 关联的 INVALIDATION_MAP actionId成功后自动失效相关 queryKey */
actionId?: string
/** 模板参数,如 { id: "cls_123" } */
params?: Record<string, string>
/** 显式额外失效的 queryKey不通过 actionId 时使用) */
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
successMessage?: string | false
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
errorMessage?: string | false
/** 成功回调 */
onSuccess?: (data: T | undefined) => void
/** 失败回调result.success === false 或抛出异常时) */
onError?: (error: unknown) => void
}
export type UseActionMutationOptions<T> =
| UseActionMutationLegacyOptions<T>
| UseActionMutationWithCacheOptions<T>
export interface UseActionMutationResult<T> { export interface UseActionMutationResult<T> {
/** 是否正在执行try/catch/finally 期间为 true */ /** 是否正在执行 */
isWorking: boolean isWorking: boolean
/** 执行 mutation。返回 ActionState 以便调用方进一步处理。 */ /** 执行 mutation。
mutate: (action: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined> * - 旧模式:传入 action 函数
* - 新模式options.mutationFn 已设):可不传参数
*/
mutate: (
action?: () => Promise<ActionState<T>>,
) => Promise<ActionState<T> | undefined>
}
function isWithCacheOptions<T>(
opts: UseActionMutationOptions<T>,
): opts is UseActionMutationWithCacheOptions<T> {
return (
opts !== null &&
typeof opts === "object" &&
("mutationFn" in opts || "actionId" in opts || "invalidateQueryKeys" in opts)
)
} }
/** /**
* 通用 Server Action mutation Hook。 * 通用 Server Action mutation Hook。
* *
* 用于替代各组件中重复的 `setIsWorking(true) + try/catch/finally + toast` 模式 * 两种模式
* - 旧模式mutate(action),手动传入 action 函数toast + onSuccess/onError
* - 新模式options.mutationFn + actionId自动 invalidateQueries
* *
* @example * @example 旧模式
* const { mutate, isWorking } = useActionMutation({ * const { mutate } = useActionMutation({ onSuccess: () => setOpen(false) })
* onSuccess: () => { setOpen(false); router.refresh() } * const handleDelete = () => mutate(() => deleteAction(id))
*
* @example 新模式
* const { mutate } = useActionMutation({
* mutationFn: () => updateClassAction(input),
* actionId: "classes.update",
* params: { id: input.classId },
* }) * })
* * const handleSubmit = () => mutate()
* const handleDelete = () => mutate(() => deleteSchoolAction(id))
*/ */
export function useActionMutation<T = unknown>( export function useActionMutation<T = unknown>(
options: UseActionMutationOptions<T> = {} options: UseActionMutationOptions<T> = {},
): UseActionMutationResult<T> { ): UseActionMutationResult<T> {
const { successMessage, errorMessage, onSuccess, onError } = options const queryClient = useQueryClient()
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const {
successMessage,
errorMessage,
onSuccess,
onError,
} = options as UseActionMutationLegacyOptions<T>
const runInvalidations = useCallback(
async (opts: UseActionMutationWithCacheOptions<T>) => {
const keysToInvalidate: (string | number | object)[][] = []
if (opts.actionId) {
const rule = (
CLIENT_INVALIDATION_MAP as Record<
string,
{ queryKeys: readonly (readonly (string | number)[])[] }
>
)[opts.actionId]
if (rule) {
for (const qk of rule.queryKeys) {
keysToInvalidate.push([...qk])
}
}
}
if (opts.invalidateQueryKeys) {
for (const qk of opts.invalidateQueryKeys) {
keysToInvalidate.push([...qk])
}
}
await Promise.all(
keysToInvalidate.map((qk) =>
queryClient.invalidateQueries({ queryKey: qk }),
),
)
},
[queryClient],
)
const mutate = useCallback( const mutate = useCallback(
async (action: () => Promise<ActionState<T>>): Promise<ActionState<T> | undefined> => { async (
action?: () => Promise<ActionState<T>>,
): Promise<ActionState<T> | undefined> => {
const cacheOpts = isWithCacheOptions(options) ? options : null
const actionToRun = action ?? cacheOpts?.mutationFn
if (!actionToRun) {
throw new Error(
"useActionMutation: must provide action to mutate() or set options.mutationFn",
)
}
setIsWorking(true) setIsWorking(true)
try { try {
const result = await action() const result = await actionToRun()
if (result.success) { if (result.success) {
if (successMessage !== false) { if (successMessage !== false) {
toast.success(successMessage ?? result.message ?? "Operation succeeded") notify.success(successMessage ?? result.message ?? "Operation succeeded")
}
if (cacheOpts) {
await runInvalidations(cacheOpts)
} }
onSuccess?.(result.data) onSuccess?.(result.data)
} else { } else {
if (errorMessage !== false) { if (errorMessage !== false) {
toast.error(errorMessage ?? result.message ?? "Operation failed") notify.error(errorMessage ?? result.message ?? "Operation failed")
} }
onError?.(new Error(result.message ?? "Action returned failure")) onError?.(new Error(result.message ?? "Action returned failure"))
} }
return result return result
} catch (error) { } catch (error) {
if (errorMessage !== false) { if (errorMessage !== false) {
toast.error(errorMessage ?? "Operation failed") notify.error(errorMessage ?? "Operation failed")
} }
onError?.(error) onError?.(error)
return undefined return undefined
@@ -67,7 +165,14 @@ export function useActionMutation<T = unknown>(
setIsWorking(false) setIsWorking(false)
} }
}, },
[successMessage, errorMessage, onSuccess, onError] [
options,
successMessage,
errorMessage,
onSuccess,
onError,
runInvalidations,
],
) )
return { isWorking, mutate } return { isWorking, mutate }

View File

@@ -1,10 +1,12 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { toast } from "sonner" import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { notify } from "@/shared/lib/notify"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
export interface UseActionQueryOptions { /** 旧版选项(向后兼容,无 queryKey */
export interface UseActionQueryLegacyOptions {
/** 依赖数组,默认 []。当依赖变化时重新获取。 */ /** 依赖数组,默认 []。当依赖变化时重新获取。 */
deps?: ReadonlyArray<unknown> deps?: ReadonlyArray<unknown>
/** 是否启用查询,默认 true。为 false 时不发起请求。 */ /** 是否启用查询,默认 true。为 false 时不发起请求。 */
@@ -13,6 +15,19 @@ export interface UseActionQueryOptions {
errorMessage?: string | false errorMessage?: string | false
} }
/** 新版选项(传入 queryKey 走 QueryClient */
export interface UseActionQueryWithCacheOptions<T>
extends Omit<UseQueryOptions<ActionState<T>>, "queryKey" | "queryFn"> {
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id)。传入后走 useQuery跨页共享缓存 */
queryKey: readonly (string | number | object)[]
/** 是否启用,默认 true */
enabled?: boolean
}
export type UseActionQueryOptions<T> =
| UseActionQueryLegacyOptions
| UseActionQueryWithCacheOptions<T>
export interface UseActionQueryResult<T> { export interface UseActionQueryResult<T> {
data: T | undefined data: T | undefined
loading: boolean loading: boolean
@@ -20,65 +35,126 @@ export interface UseActionQueryResult<T> {
refetch: () => void refetch: () => void
} }
function isWithCacheOptions<T>(
opts: UseActionQueryOptions<T>,
): opts is UseActionQueryWithCacheOptions<T> {
return opts !== null && typeof opts === "object" && "queryKey" in opts
}
/** /**
* 通用 Server Action 查询 Hook。 * 通用 Server Action 查询 Hook。
* *
* 用于替代各组件中重复的 `useEffect + useState(loading) + Action().then().catch().finally()` 模式 * 两种模式
* 内置竞态防护(通过 ref 跟踪最新请求)。 * - 旧模式(不传 queryKeyuseEffect + useState请求内去重不跨页共享
* - 新模式(传 queryKeyuseQuery跨页共享缓存支持自动 invalidate
* *
* @example * 实现说明:为遵守 React Hooks 规则useQuery 与 legacy state/effect 始终被声明,
* const { data, loading } = useActionQuery( * 通过 `isCacheMode` 切换启用状态,二选一返回结果。
* () => getKnowledgePointOptionsAction(), *
* { deps: [open], enabled: open } * @example 旧模式
* const { data } = useActionQuery(() => getXAction(), { deps: [id] })
*
* @example 新模式
* const { data } = useActionQuery(
* () => getClassDetailAction({ classId }),
* { queryKey: queryKeys.classes.detail(classId) }
* ) * )
*/ */
export function useActionQuery<T>( export function useActionQuery<T>(
action: () => Promise<ActionState<T>>, action: () => Promise<ActionState<T>>,
options: UseActionQueryOptions = {} options: UseActionQueryOptions<T> = {},
): UseActionQueryResult<T> { ): UseActionQueryResult<T> {
const { deps = [], enabled = true, errorMessage = "Failed to load data" } = options const isCacheMode = isWithCacheOptions(options)
const cacheOpts = (isCacheMode ? options : {}) as Partial<UseActionQueryWithCacheOptions<T>>
const legacyOpts = (isCacheMode ? {} : options) as UseActionQueryLegacyOptions
const { deps = [], enabled: legacyEnabled = true, errorMessage = "Failed to load data" } = legacyOpts
const { queryKey: cacheQueryKey, enabled: cacheEnabledFlag, ...restCache } = cacheOpts
// useQuery 始终声明legacy 模式下 enabled:false 避免发起请求
const query = useQuery<ActionState<T>>({
...restCache,
queryKey: cacheQueryKey ?? ["__useActionQueryLegacy__"],
queryFn: action,
enabled: isCacheMode ? (cacheEnabledFlag ?? true) : false,
})
// legacy state 始终声明cache 模式下 effect 直接 return
const [data, setData] = useState<T | undefined>(undefined) const [data, setData] = useState<T | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(enabled)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [refetchCount, setRefetchCount] = useState(0) const [loading, setLoading] = useState(false)
const requestIdRef = useRef(0)
useEffect(() => { useEffect(() => {
if (!enabled) return if (isCacheMode || !legacyEnabled) return
const requestId = ++requestIdRef.current
let cancelled = false
setLoading(true) setLoading(true)
setError(null) setError(null)
action() action()
.then((result) => { .then((result) => {
if (cancelled) return if (requestId !== requestIdRef.current) return
if (result.success) { if (result.success) {
setData(result.data) setData(result.data)
} else { } else {
setError(new Error(result.message ?? "Action failed")) setError(new Error(result.message ?? "Action failed"))
if (errorMessage !== false) { if (errorMessage !== false) {
toast.error(errorMessage) notify.error(errorMessage)
} }
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (cancelled) return if (requestId !== requestIdRef.current) return
setError(err instanceof Error ? err : new Error(String(err))) setError(err instanceof Error ? err : new Error(String(err)))
if (errorMessage !== false) { if (errorMessage !== false) {
toast.error(errorMessage) notify.error(errorMessage)
} }
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false) if (requestId === requestIdRef.current) setLoading(false)
}) })
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, refetchCount]) }, [isCacheMode, legacyEnabled, errorMessage, ...deps])
const refetch = () => setRefetchCount((c) => c + 1) const legacyRefetch = () => {
if (isCacheMode) return
const requestId = ++requestIdRef.current
setLoading(true)
action()
.then((result) => {
if (requestId !== requestIdRef.current) return
if (result.success) {
setData(result.data)
} else {
setError(new Error(result.message ?? "Action failed"))
if (errorMessage !== false) notify.error(errorMessage)
}
})
.catch((err: unknown) => {
if (requestId !== requestIdRef.current) return
setError(err instanceof Error ? err : new Error(String(err)))
if (errorMessage !== false) notify.error(errorMessage)
})
.finally(() => {
if (requestId === requestIdRef.current) setLoading(false)
})
}
return { data, loading, error, refetch } if (isCacheMode) {
const cacheData = query.data?.success ? query.data.data : undefined
const cacheError =
query.error ??
(query.data && !query.data.success
? new Error(query.data.message ?? "Action failed")
: null)
return {
data: cacheData,
loading: query.isLoading,
error: cacheError,
refetch: () => {
void query.refetch()
},
}
}
return { data, loading, error, refetch: legacyRefetch }
} }