From 7567f317e1f7d8a8dc214f501d97e2828079c2fc Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:24:07 +0800 Subject: [PATCH] feat(modules): add leave-requests, invitation-codes, and standards modules - Add leave-requests module for staff and student leave request management - Add invitation-codes module for class invitation code generation and redemption - Add standards module for curriculum standards management --- src/modules/invitation-codes/actions.ts | 158 +++++++ .../generate-invitation-codes-dialog.tsx | 389 ++++++++++++++++++ .../components/invitation-codes-view.tsx | 305 ++++++++++++++ src/modules/invitation-codes/data-access.ts | 247 +++++++++++ .../invitation-codes/lib/code-generator.ts | 66 +++ src/modules/invitation-codes/schema.ts | 46 +++ src/modules/invitation-codes/types.ts | 64 +++ src/modules/leave-requests/actions.ts | 257 ++++++++++++ .../components/leave-request-form.tsx | 195 +++++++++ .../components/leave-request-list.tsx | 117 ++++++ .../components/leave-review-dialog.tsx | 161 ++++++++ .../components/leave-review-list.tsx | 118 ++++++ src/modules/leave-requests/data-access.ts | 333 +++++++++++++++ src/modules/leave-requests/schema.ts | 35 ++ src/modules/leave-requests/types.ts | 63 +++ src/modules/standards/actions.ts | 222 ++++++++++ src/modules/standards/data-access.ts | 265 ++++++++++++ src/modules/standards/schema.ts | 68 +++ src/modules/standards/types.ts | 83 ++++ 19 files changed, 3192 insertions(+) create mode 100644 src/modules/invitation-codes/actions.ts create mode 100644 src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx create mode 100644 src/modules/invitation-codes/components/invitation-codes-view.tsx create mode 100644 src/modules/invitation-codes/data-access.ts create mode 100644 src/modules/invitation-codes/lib/code-generator.ts create mode 100644 src/modules/invitation-codes/schema.ts create mode 100644 src/modules/invitation-codes/types.ts create mode 100644 src/modules/leave-requests/actions.ts create mode 100644 src/modules/leave-requests/components/leave-request-form.tsx create mode 100644 src/modules/leave-requests/components/leave-request-list.tsx create mode 100644 src/modules/leave-requests/components/leave-review-dialog.tsx create mode 100644 src/modules/leave-requests/components/leave-review-list.tsx create mode 100644 src/modules/leave-requests/data-access.ts create mode 100644 src/modules/leave-requests/schema.ts create mode 100644 src/modules/leave-requests/types.ts create mode 100644 src/modules/standards/actions.ts create mode 100644 src/modules/standards/data-access.ts create mode 100644 src/modules/standards/schema.ts create mode 100644 src/modules/standards/types.ts diff --git a/src/modules/invitation-codes/actions.ts b/src/modules/invitation-codes/actions.ts new file mode 100644 index 0000000..5b0013b --- /dev/null +++ b/src/modules/invitation-codes/actions.ts @@ -0,0 +1,158 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import type { ActionState } from "@/shared/types/action-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { trackEvent } from "@/shared/lib/track-event" + +import { + generateInvitationCodes as generateInvitationCodesDa, + listInvitationCodes as listInvitationCodesDa, + deleteInvitationCode as deleteInvitationCodeDa, + validateInvitationCode as validateInvitationCodeDa, +} from "./data-access" +import { GenerateInvitationCodesSchema } from "./schema" +import type { + GenerateInvitationCodesResult, + InvitationCodeRecord, +} from "./types" + +/** + * 批量生成邀请码(audit-P2-3 新增) + * + * 权限:USER_MANAGE(管理员) + * 输入:{ count, role, email?, classId?, expiresAt?, notes? } + * 返回:含明文 code 的完整记录列表(仅此一次返回明文) + */ +export async function generateInvitationCodesAction( + input: unknown, +): Promise> { + try { + const ctx = await requirePermission(Permissions.USER_MANAGE) + + const parsed = GenerateInvitationCodesSchema.safeParse(input) + if (!parsed.success) { + return { + success: false, + message: parsed.error.issues[0]?.message ?? "Invalid input", + } + } + + const data = parsed.data + const result = await generateInvitationCodesDa( + { + count: data.count, + role: data.role, + email: data.email?.trim() || null, + classId: data.classId?.trim() || null, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + notes: data.notes?.trim() || null, + }, + ctx.userId, + ) + + void trackEvent({ + event: "invitation_codes.generated", + userId: ctx.userId, + properties: { + count: result.codes.length, + role: data.role, + batchId: result.batchId, + }, + }) + + revalidatePath("/admin/invitation-codes") + + return { success: true, data: result } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to generate invitation codes" + return { success: false, message } + } +} + +/** + * 列出邀请码(audit-P2-3 新增) + * + * 权限:USER_MANAGE(管理员) + * 返回:邀请码记录列表(已使用 + 未使用,按创建时间倒序) + */ +export async function listInvitationCodesAction(): Promise< + ActionState +> { + try { + await requirePermission(Permissions.USER_MANAGE) + + const records = await listInvitationCodesDa(200, true) + return { success: true, data: records } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to list invitation codes" + return { success: false, message } + } +} + +/** + * 删除邀请码(audit-P2-3 新增) + * + * 权限:USER_MANAGE(管理员) + * 仅允许删除未被使用的邀请码(已使用的保留作为审计记录)。 + */ +export async function deleteInvitationCodeAction( + codeId: string, +): Promise> { + try { + const ctx = await requirePermission(Permissions.USER_MANAGE) + + const deleted = await deleteInvitationCodeDa(codeId) + if (!deleted) { + return { + success: false, + message: + "Invitation code not found or already used (used codes cannot be deleted)", + } + } + + void trackEvent({ + event: "invitation_codes.deleted", + userId: ctx.userId, + targetId: codeId, + }) + + revalidatePath("/admin/invitation-codes") + return { success: true, data: null } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to delete invitation code" + return { success: false, message } + } +} + +/** + * 校验邀请码(公开接口,供注册流程预检,audit-P2-3 新增) + * + * 不需要权限校验:注册流程需要预检邀请码可用性。 + * 不返回邀请码明文(脱敏)。 + */ +export async function validateInvitationCodeAction( + code: string, + email: string, +): Promise> { + try { + const result = await validateInvitationCodeDa(code, email) + return { + success: true, + data: { + valid: result.valid, + reason: result.reason, + role: result.code?.role, + }, + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to validate invitation code" + return { success: false, message } + } +} diff --git a/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx b/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx new file mode 100644 index 0000000..687bbbf --- /dev/null +++ b/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx @@ -0,0 +1,389 @@ +"use client" + +import * as React from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Copy, Hash, Mail, GraduationCap, Clock, FileText } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { useActionMutation } from "@/shared/hooks/use-action-mutation" + +import { generateInvitationCodesAction } from "../actions" +import { INVITATION_ROLE_VALUES } from "../schema" +import type { InvitationCodeRecord, InvitationRole } from "../types" + +/** + * 生成邀请码对话框(audit-P2-3 新增)。 + * + * 流程: + * 1. 用户填写表单(数量、角色、可选邮箱/班级/过期/备注) + * 2. 提交调用 generateInvitationCodesAction + * 3. 成功后切换到「生成结果」视图,展示明文邀请码供复制 + * 4. 关闭对话框时通过 onGenerated 回调通知父组件刷新 + * + * 设计: + * - 单对话框内两个状态(form / result),避免叠加多个 Dialog + * - result 视图仅此一次展示明文,关闭后无法再获取 + */ +interface GenerateInvitationCodesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onGenerated: () => void +} + +type ViewState = "form" | "result" + +export function GenerateInvitationCodesDialog({ + open, + onOpenChange, + onGenerated, +}: GenerateInvitationCodesDialogProps) { + const t = useTranslations("invitationCodes") + const [view, setView] = React.useState("form") + const [generatedCodes, setGeneratedCodes] = React.useState([]) + const [batchId, setBatchId] = React.useState("") + + // 表单状态 + const [count, setCount] = React.useState("10") + const [role, setRole] = React.useState("student") + const [email, setEmail] = React.useState("") + const [classId, setClassId] = React.useState("") + const [expiresAt, setExpiresAt] = React.useState("") + const [notes, setNotes] = React.useState("") + + const generateMutation = useActionMutation<{ codes: InvitationCodeRecord[]; batchId: string }>({ + successMessage: false, + errorMessage: t("generate.failed"), + onSuccess: (data) => { + if (data) { + setGeneratedCodes(data.codes) + setBatchId(data.batchId) + setView("result") + toast.success(t("generate.success", { count: data.codes.length })) + } + }, + }) + + // 对话框关闭时重置状态 + const handleOpenChange = (nextOpen: boolean): void => { + if (!nextOpen) { + // 关闭前若已生成,通知父组件刷新 + if (view === "result") { + onGenerated() + } + // 延迟重置以便关闭动画完成 + setTimeout(() => { + setView("form") + setGeneratedCodes([]) + setBatchId("") + }, 200) + } + onOpenChange(nextOpen) + } + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + // datetime-local 输入无时区后缀,需转为 ISO 8601(UTC)以通过 schema 校验 + const normalizedExpiry = expiresAt + ? new Date(expiresAt).toISOString() + : undefined + void generateMutation.mutate(() => + generateInvitationCodesAction({ + count: Number(count), + role, + email, + classId, + expiresAt: normalizedExpiry, + notes, + }), + ) + } + + const handleCopyAll = async (): Promise => { + const text = generatedCodes.map((c) => c.code).join("\n") + try { + await navigator.clipboard.writeText(text) + toast.success(t("actions.copied")) + } catch { + toast.error(t("actions.copyFailed")) + } + } + + const handleCopyOne = async (code: string): Promise => { + try { + await navigator.clipboard.writeText(code) + toast.success(t("actions.copied")) + } catch { + toast.error(t("actions.copyFailed")) + } + } + + return ( + + + {view === "form" ? ( + onOpenChange(false)} + /> + ) : ( + onOpenChange(false)} + /> + )} + + + ) +} + +interface GenerateFormProps { + t: ReturnType + count: string + setCount: (v: string) => void + role: InvitationRole + setRole: (v: InvitationRole) => void + email: string + setEmail: (v: string) => void + classId: string + setClassId: (v: string) => void + expiresAt: string + setExpiresAt: (v: string) => void + notes: string + setNotes: (v: string) => void + isSubmitting: boolean + onSubmit: (e: React.FormEvent) => void + onCancel: () => void +} + +function GenerateForm({ + t, + count, + setCount, + role, + setRole, + email, + setEmail, + classId, + setClassId, + expiresAt, + setExpiresAt, + notes, + setNotes, + isSubmitting, + onSubmit, + onCancel, +}: GenerateFormProps) { + return ( + <> + + {t("generate.title")} + {t("generate.description")} + +
+
+ + setCount(e.target.value)} + required + /> +

{t("generate.countDescription")}

+
+ +
+ + +
+ +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +

{t("generate.emailDescription")}

+
+ +
+ + setClassId(e.target.value)} + placeholder="cls_xxxxxxxx" + /> +

{t("generate.classIdDescription")}

+
+ +
+ + setExpiresAt(e.target.value)} + /> +

{t("generate.expiresAtDescription")}

+
+ +
+ +