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:
SpecialX
2026-07-05 18:57:10 +08:00
parent 9eb02807a4
commit a3dc22cb9e
5 changed files with 69 additions and 52 deletions

View File

@@ -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")

View File

@@ -42,6 +42,7 @@ export {
setStudentEnrollmentStatusAction,
bulkEnrollStudentsAction,
bulkAssignSubjectTeachersAction,
type ClassInvitationCodeOption,
} from "./actions-invitations"
export {

View File

@@ -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 ?? []
// 撤销 mutationactionId 命中 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) {