refactor(classes): 客户端组件迁移至 useQuery + queryKeys 示范
Task 19: 将 class-invitation-manager.tsx 从 initialCodes prop + 本地 codes state + 手动 Action 调用 + 手动 state 更新模式,迁移至 V5 缓存策略新 API: - 列表查询: useActionQuery + queryKeys.classes.invitations(classId) - 撤销 mutation: useActionMutation + actionId "classes.invitation.revoke" (成功后由 CLIENT_INVALIDATION_MAP 自动失效 ["classes", "invitations"] 前缀) - 生成回调: queryClient.invalidateQueries 手动失效本班级 invitations 查询 - 移除 initialCodes prop + codes state + isSubmitting 手动管理 配套改动: - actions-invitations.ts: 新增并导出 ClassInvitationCodeOption 强类型接口, listClassInvitationCodesAction 返回类型从 Array<Record<string, unknown>> 收窄为 ClassInvitationCodeOption[] - actions.ts barrel: 补充 type ClassInvitationCodeOption 与遗漏的 bulkEnrollStudentsAction / bulkAssignSubjectTeachersAction 导出 - 架构文档 004/005 同步更新 hook 消费方与 actions.ts exports 清单
This commit is contained in:
@@ -691,8 +691,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
| Hook | 文件 | 签名 | 用途 | 消费方 |
|
||||
|------|------|------|------|--------|
|
||||
| `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 }`。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 个文件 |
|
||||
| `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 自动失效,向后兼容旧调用方 | 示范:P1-4 schools-view;Task 19 classes/class-invitation-manager(revoke mutation + actionId 自动失效)。潜在影响 50+ 文件 |
|
||||
| `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,向后兼容旧调用方 | 示范:P1-4 create-question-dialog;Task 19 classes/class-invitation-manager(listClassInvitationCodesAction + queryKeys.classes.invitations(classId))。潜在影响 11 个文件 |
|
||||
|
||||
**V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更):
|
||||
|
||||
|
||||
@@ -8254,9 +8254,12 @@
|
||||
"revokeClassInvitationCodeAction",
|
||||
"listClassInvitationCodesAction",
|
||||
"setStudentEnrollmentStatusAction",
|
||||
"bulkEnrollStudentsAction",
|
||||
"bulkAssignSubjectTeachersAction",
|
||||
"createClassScheduleItemAction",
|
||||
"updateClassScheduleItemAction",
|
||||
"deleteClassScheduleItemAction"
|
||||
"deleteClassScheduleItemAction",
|
||||
"ClassInvitationCodeOption"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -316,10 +316,25 @@ export async function revokeClassInvitationCodeAction(
|
||||
|
||||
/**
|
||||
* v3 新增:列出班级所有邀请码(管理端列表用)。
|
||||
*
|
||||
* 返回值采用强类型 `ClassInvitationCodeOption`(日期已序列化为 ISO 字符串),
|
||||
* 便于客户端通过 `useActionQuery` + `queryKeys.classes.invitations(classId)` 直接消费。
|
||||
*/
|
||||
export interface ClassInvitationCodeOption {
|
||||
id: string
|
||||
code: string
|
||||
status: "active" | "disabled" | "expired" | "exhausted"
|
||||
maxUses: number | null
|
||||
usedCount: number
|
||||
expiresAt: string | null
|
||||
createdAt: string
|
||||
revokedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export async function listClassInvitationCodesAction(
|
||||
classId: string
|
||||
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
|
||||
): Promise<ActionState<{ codes: ClassInvitationCodeOption[] }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
@@ -42,6 +42,7 @@ export {
|
||||
setStudentEnrollmentStatusAction,
|
||||
bulkEnrollStudentsAction,
|
||||
bulkAssignSubjectTeachersAction,
|
||||
type ClassInvitationCodeOption,
|
||||
} from "./actions-invitations"
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Copy, Plus, Ban, Clock, Hash } from "lucide-react"
|
||||
@@ -26,14 +27,26 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
||||
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||
import { queryKeys } from "@/shared/lib/query-keys"
|
||||
import {
|
||||
type ClassInvitationCodeOption,
|
||||
createClassInvitationCodeAction,
|
||||
listClassInvitationCodesAction,
|
||||
revokeClassInvitationCodeAction,
|
||||
} from "@/modules/classes/actions"
|
||||
|
||||
/**
|
||||
* 班级邀请码管理面板(v3 新增,对标 Google Classroom / 钉钉教育)。
|
||||
*
|
||||
* 数据获取(Task 19 已迁移至 useQuery + queryKeys 工厂):
|
||||
* - 列表查询:`useActionQuery` + `queryKeys.classes.invitations(classId)`
|
||||
* - 撤销 mutation:`useActionMutation` + `actionId: "classes.invitation.revoke"`
|
||||
* 成功后由 `CLIENT_INVALIDATION_MAP` 自动失效 `["classes", "invitations"]` 前缀
|
||||
* - 生成 mutation:由 `GenerateCodeDialog` 直接调用 action,父组件在
|
||||
* `onCreated` 回调中手动 `invalidateQueries` 触发列表刷新
|
||||
*
|
||||
* 功能:
|
||||
* - 列出班级所有邀请码(含状态/有效期/使用次数)
|
||||
* - 生成自定义邀请码(可选有效期/次数/备注)
|
||||
@@ -42,32 +55,32 @@ import {
|
||||
*
|
||||
* 权限:调用方需确保用户拥有 CLASS_ENROLL 权限(actions 内部已校验)
|
||||
*/
|
||||
interface InvitationCodeRecord {
|
||||
id: string
|
||||
code: string
|
||||
status: "active" | "disabled" | "expired" | "exhausted"
|
||||
maxUses: number | null
|
||||
usedCount: number
|
||||
expiresAt: string | null
|
||||
createdAt: string
|
||||
revokedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
interface ClassInvitationManagerProps {
|
||||
classId: string
|
||||
initialCodes: InvitationCodeRecord[]
|
||||
}
|
||||
|
||||
export function ClassInvitationManager({
|
||||
classId,
|
||||
initialCodes,
|
||||
}: ClassInvitationManagerProps) {
|
||||
const t = useTranslations("classes.invitation")
|
||||
const [codes, setCodes] = React.useState<InvitationCodeRecord[]>(initialCodes)
|
||||
const queryClient = useQueryClient()
|
||||
const [isGenerateOpen, setIsGenerateOpen] = React.useState(false)
|
||||
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const [revokeTarget, setRevokeTarget] = React.useState<ClassInvitationCodeOption | null>(null)
|
||||
|
||||
// 列表查询:传入 queryKey 后走 useQuery,跨页共享缓存,支持自动 invalidate
|
||||
const { data: codesData } = useActionQuery(
|
||||
() => listClassInvitationCodesAction(classId),
|
||||
{ queryKey: queryKeys.classes.invitations(classId), errorMessage: false },
|
||||
)
|
||||
const codes = codesData?.codes ?? []
|
||||
|
||||
// 撤销 mutation:actionId 命中 CLIENT_INVALIDATION_MAP,自动 invalidate
|
||||
const revokeMutation = useActionMutation<null>({
|
||||
actionId: "classes.invitation.revoke",
|
||||
successMessage: t("revokeSuccess"),
|
||||
errorMessage: t("revokeFailed"),
|
||||
onSuccess: () => setRevokeTarget(null),
|
||||
})
|
||||
|
||||
const handleCopy = async (code: string): Promise<void> => {
|
||||
try {
|
||||
@@ -78,31 +91,18 @@ export function ClassInvitationManager({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (): Promise<void> => {
|
||||
const handleRevoke = (): void => {
|
||||
if (!revokeTarget) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("codeId", revokeTarget.id)
|
||||
const result = await revokeClassInvitationCodeAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(t("revokeSuccess"))
|
||||
setCodes((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === revokeTarget.id
|
||||
? { ...c, status: "disabled", revokedAt: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
setRevokeTarget(null)
|
||||
} else {
|
||||
toast.error(result.message ?? t("revokeFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("revokeFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.set("codeId", revokeTarget.id)
|
||||
void revokeMutation.mutate(() => revokeClassInvitationCodeAction(null, formData))
|
||||
}
|
||||
|
||||
// GenerateCodeDialog 内部直接调用 action,成功后手动失效本班级 invitations 查询
|
||||
const handleCreated = (): void => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.classes.invitations(classId),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -119,9 +119,7 @@ export function ClassInvitationManager({
|
||||
<GenerateCodeDialog
|
||||
classId={classId}
|
||||
onClose={() => setIsGenerateOpen(false)}
|
||||
onCreated={(record) => {
|
||||
setCodes((prev) => [record, ...prev])
|
||||
}}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -199,10 +197,10 @@ export function ClassInvitationManager({
|
||||
<DialogDescription>{t("revokeConfirm")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)} disabled={isSubmitting}>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)} disabled={revokeMutation.isWorking}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={isSubmitting}>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={revokeMutation.isWorking}>
|
||||
{t("revoke")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -212,7 +210,7 @@ export function ClassInvitationManager({
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: InvitationCodeRecord["status"] }) {
|
||||
function StatusBadge({ status }: { status: ClassInvitationCodeOption["status"] }) {
|
||||
const t = useTranslations("classes.invitation")
|
||||
const variant = status === "active" ? "default" : "secondary"
|
||||
return <Badge variant={variant}>{t(status)}</Badge>
|
||||
@@ -221,7 +219,7 @@ function StatusBadge({ status }: { status: InvitationCodeRecord["status"] }) {
|
||||
interface GenerateCodeDialogProps {
|
||||
classId: string
|
||||
onClose: () => void
|
||||
onCreated: (record: InvitationCodeRecord) => void
|
||||
onCreated: (record: ClassInvitationCodeOption) => void
|
||||
}
|
||||
|
||||
function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogProps) {
|
||||
|
||||
Reference in New Issue
Block a user