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
This commit is contained in:
158
src/modules/invitation-codes/actions.ts
Normal file
158
src/modules/invitation-codes/actions.ts
Normal file
@@ -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<ActionState<GenerateInvitationCodesResult>> {
|
||||||
|
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<InvitationCodeRecord[]>
|
||||||
|
> {
|
||||||
|
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<ActionState<null>> {
|
||||||
|
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<ActionState<{ valid: boolean; reason?: string; role?: string }>> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ViewState>("form")
|
||||||
|
const [generatedCodes, setGeneratedCodes] = React.useState<InvitationCodeRecord[]>([])
|
||||||
|
const [batchId, setBatchId] = React.useState<string>("")
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [count, setCount] = React.useState("10")
|
||||||
|
const [role, setRole] = React.useState<InvitationRole>("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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
toast.success(t("actions.copied"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("actions.copyFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
|
{view === "form" ? (
|
||||||
|
<GenerateForm
|
||||||
|
t={t}
|
||||||
|
count={count}
|
||||||
|
setCount={setCount}
|
||||||
|
role={role}
|
||||||
|
setRole={setRole}
|
||||||
|
email={email}
|
||||||
|
setEmail={setEmail}
|
||||||
|
classId={classId}
|
||||||
|
setClassId={setClassId}
|
||||||
|
expiresAt={expiresAt}
|
||||||
|
setExpiresAt={setExpiresAt}
|
||||||
|
notes={notes}
|
||||||
|
setNotes={setNotes}
|
||||||
|
isSubmitting={generateMutation.isWorking}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GenerateResult
|
||||||
|
t={t}
|
||||||
|
codes={generatedCodes}
|
||||||
|
batchId={batchId}
|
||||||
|
onCopyOne={handleCopyOne}
|
||||||
|
onCopyAll={handleCopyAll}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateFormProps {
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("generate.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("generate.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={onSubmit} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="count" className="flex items-center gap-1.5">
|
||||||
|
<Hash className="h-3.5 w-3.5" />
|
||||||
|
{t("generate.count")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={count}
|
||||||
|
onChange={(e) => setCount(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("generate.countDescription")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="role">{t("generate.role")}</Label>
|
||||||
|
<Select
|
||||||
|
value={role}
|
||||||
|
onValueChange={(v) => setRole(v as InvitationRole)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{INVITATION_ROLE_VALUES.map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>
|
||||||
|
{t(`roles.${r}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email" className="flex items-center gap-1.5">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
{t("generate.email")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("generate.emailDescription")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="classId" className="flex items-center gap-1.5">
|
||||||
|
<GraduationCap className="h-3.5 w-3.5" />
|
||||||
|
{t("generate.classId")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="classId"
|
||||||
|
value={classId}
|
||||||
|
onChange={(e) => setClassId(e.target.value)}
|
||||||
|
placeholder="cls_xxxxxxxx"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("generate.classIdDescription")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="expiresAt" className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{t("generate.expiresAt")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("generate.expiresAtDescription")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notes" className="flex items-center gap-1.5">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
{t("generate.notes")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("generate.notesDescription")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t("generate.generating") : t("generate.submit")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateResultProps {
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
codes: InvitationCodeRecord[]
|
||||||
|
batchId: string
|
||||||
|
onCopyOne: (code: string) => void
|
||||||
|
onCopyAll: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenerateResult({
|
||||||
|
t,
|
||||||
|
codes,
|
||||||
|
batchId,
|
||||||
|
onCopyOne,
|
||||||
|
onCopyAll,
|
||||||
|
onClose,
|
||||||
|
}: GenerateResultProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("generatedList.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("generatedList.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded-md bg-muted px-3 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("generatedList.batchId")}:<code className="ml-1 font-mono">{batchId}</code>
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onCopyAll}>
|
||||||
|
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t("generatedList.copyAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[40vh] rounded-md border">
|
||||||
|
<div className="divide-y">
|
||||||
|
{codes.map((record) => (
|
||||||
|
<div
|
||||||
|
key={record.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<code className="font-mono text-sm font-medium tracking-wider">
|
||||||
|
{record.code}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onCopyOne(record.code)}
|
||||||
|
aria-label={t("actions.copy")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>{t("generatedList.close")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { TicketPlus, Copy, Trash2, CheckCircle2, XCircle, Clock } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
|
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||||
|
import { formatDateTime } from "@/shared/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { deleteInvitationCodeAction } from "../actions"
|
||||||
|
import type { InvitationCodeRecord, InvitationRole } from "../types"
|
||||||
|
import { GenerateInvitationCodesDialog } from "./generate-invitation-codes-dialog"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请码管理视图(audit-P2-3 新增)。
|
||||||
|
*
|
||||||
|
* 由 admin/invitation-codes/page.tsx 的 Server Component 通过
|
||||||
|
* listInvitationCodesAction 获取初始数据后注入,避免本组件内部直接调用
|
||||||
|
* Server Action 影响首屏 SSR。后续生成/删除通过 router.refresh() 触发
|
||||||
|
* 服务端重新渲染并更新 initialCodes。
|
||||||
|
*
|
||||||
|
* `now` 由 Server Component 计算后传入,避免在 Client render 阶段调用
|
||||||
|
* Date.now()(react-hooks/purity 规则)。
|
||||||
|
*/
|
||||||
|
interface InvitationCodesViewProps {
|
||||||
|
initialCodes: InvitationCodeRecord[]
|
||||||
|
/** Server Component 计算的「当前时间戳」(ms),用于过期判断 */
|
||||||
|
now: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABEL_KEYS: Record<InvitationRole, string> = {
|
||||||
|
student: "roles.student",
|
||||||
|
teacher: "roles.teacher",
|
||||||
|
parent: "roles.parent",
|
||||||
|
admin: "roles.admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvitationCodesView({ initialCodes, now }: InvitationCodesViewProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("invitationCodes")
|
||||||
|
const [isGenerateOpen, setIsGenerateOpen] = React.useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<InvitationCodeRecord | null>(null)
|
||||||
|
|
||||||
|
// 统计:依赖传入的 now,不调用 Date.now()
|
||||||
|
const stats = React.useMemo(() => {
|
||||||
|
const used = initialCodes.filter((c) => c.usedBy !== null).length
|
||||||
|
const expired = initialCodes.filter(
|
||||||
|
(c) => c.usedBy === null && c.expiresAt !== null && c.expiresAt.getTime() < now,
|
||||||
|
).length
|
||||||
|
const unused = initialCodes.length - used - expired
|
||||||
|
return {
|
||||||
|
total: initialCodes.length,
|
||||||
|
unused,
|
||||||
|
used,
|
||||||
|
expired,
|
||||||
|
}
|
||||||
|
}, [initialCodes, now])
|
||||||
|
|
||||||
|
const deleteMutation = useActionMutation<null>({
|
||||||
|
successMessage: t("actions.deleted"),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null)
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCopy = async (code: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
toast.success(t("actions.copied"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("actions.copyFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (): void => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
void deleteMutation.mutate(() => deleteInvitationCodeAction(deleteTarget.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerated = (): void => {
|
||||||
|
setIsGenerateOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
|
{/* 头部 + 生成按钮 */}
|
||||||
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TicketPlus className="h-7 w-7 text-muted-foreground" />
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setIsGenerateOpen(true)}>
|
||||||
|
<TicketPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t("generate.title")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.total")}
|
||||||
|
value={stats.total}
|
||||||
|
icon={TicketPlus}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.unused")}
|
||||||
|
value={stats.unused}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
color="text-emerald-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.used")}
|
||||||
|
value={stats.used}
|
||||||
|
icon={XCircle}
|
||||||
|
color="text-blue-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.expired")}
|
||||||
|
value={stats.expired}
|
||||||
|
icon={Clock}
|
||||||
|
color="text-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邀请码表格 */}
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{initialCodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={TicketPlus}
|
||||||
|
title={t("empty.title")}
|
||||||
|
description={t("empty.description")}
|
||||||
|
action={{
|
||||||
|
label: t("generate.title"),
|
||||||
|
onClick: () => setIsGenerateOpen(true),
|
||||||
|
variant: "default",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="font-mono">{t("columns.code")}</TableHead>
|
||||||
|
<TableHead>{t("columns.role")}</TableHead>
|
||||||
|
<TableHead>{t("columns.email")}</TableHead>
|
||||||
|
<TableHead>{t("columns.status")}</TableHead>
|
||||||
|
<TableHead>{t("columns.expiresAt")}</TableHead>
|
||||||
|
<TableHead>{t("columns.createdAt")}</TableHead>
|
||||||
|
<TableHead>{t("columns.usedBy")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("columns.actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{initialCodes.map((record) => (
|
||||||
|
<InvitationCodeRow
|
||||||
|
key={record.id}
|
||||||
|
record={record}
|
||||||
|
now={now}
|
||||||
|
onCopy={() => handleCopy(record.code)}
|
||||||
|
onDelete={() => setDeleteTarget(record)}
|
||||||
|
roleLabel={t(ROLE_LABEL_KEYS[record.role])}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 生成对话框 */}
|
||||||
|
<GenerateInvitationCodesDialog
|
||||||
|
open={isGenerateOpen}
|
||||||
|
onOpenChange={setIsGenerateOpen}
|
||||||
|
onGenerated={handleGenerated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("actions.deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("actions.deleteConfirmDescription")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteMutation.isWorking}>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isWorking}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteMutation.isWorking
|
||||||
|
? t("actions.deleting")
|
||||||
|
: t("actions.confirmDelete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvitationCodeRowProps {
|
||||||
|
record: InvitationCodeRecord
|
||||||
|
now: number
|
||||||
|
onCopy: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
roleLabel: string
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationCodeRow({ record, now, onCopy, onDelete, roleLabel, t }: InvitationCodeRowProps) {
|
||||||
|
const isUsed = record.usedBy !== null
|
||||||
|
const isExpired =
|
||||||
|
!isUsed && record.expiresAt !== null && record.expiresAt.getTime() < now
|
||||||
|
const statusKey = isUsed ? "used" : isExpired ? "expired" : "unused"
|
||||||
|
const statusVariant = isUsed ? "secondary" : isExpired ? "outline" : "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono font-medium tracking-wider">
|
||||||
|
{record.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{roleLabel}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[180px] truncate text-sm text-muted-foreground">
|
||||||
|
{record.email ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant as "default" | "secondary" | "outline"}>
|
||||||
|
{t(`status.${statusKey}`)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{record.expiresAt ? formatDateTime(record.expiresAt) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatDateTime(record.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[120px] truncate text-sm text-muted-foreground">
|
||||||
|
{record.usedBy ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCopy}
|
||||||
|
aria-label={t("actions.copy")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!isUsed && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label={t("actions.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
247
src/modules/invitation-codes/data-access.ts
Normal file
247
src/modules/invitation-codes/data-access.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, desc, eq, isNull, lte, or } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { invitationCodes } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConsumeInvitationCodeInput,
|
||||||
|
GenerateInvitationCodesInput,
|
||||||
|
InvitationCodeRecord,
|
||||||
|
InvitationRole,
|
||||||
|
ValidateInvitationCodeResult,
|
||||||
|
} from "./types"
|
||||||
|
import {
|
||||||
|
generateBatchId,
|
||||||
|
generateUniqueInvitationCodes,
|
||||||
|
} from "./lib/code-generator"
|
||||||
|
|
||||||
|
/** 数据库行 → InvitationCodeRecord 映射 */
|
||||||
|
function toRecord(row: typeof invitationCodes.$inferSelect): InvitationCodeRecord {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
code: row.code,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role as InvitationRole,
|
||||||
|
classId: row.classId,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
usedBy: row.usedBy,
|
||||||
|
usedAt: row.usedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成邀请码(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 1. 使用纯函数生成 count 个唯一明文邀请码
|
||||||
|
* 2. 批量插入数据库(若遇唯一约束冲突,重新生成冲突项)
|
||||||
|
* 3. 返回完整记录(含明文 code,仅此一次返回完整明文)
|
||||||
|
*/
|
||||||
|
export async function generateInvitationCodes(
|
||||||
|
input: GenerateInvitationCodesInput,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<{ codes: InvitationCodeRecord[]; batchId: string }> {
|
||||||
|
const batchId = generateBatchId()
|
||||||
|
const codes = generateUniqueInvitationCodes(input.count)
|
||||||
|
const notes = input.notes?.trim() || null
|
||||||
|
const expiresAt = input.expiresAt ?? null
|
||||||
|
|
||||||
|
const rows = codes.map((code) => ({
|
||||||
|
code,
|
||||||
|
email: input.email?.trim() || null,
|
||||||
|
role: input.role,
|
||||||
|
classId: input.classId?.trim() || null,
|
||||||
|
notes: notes ?? `${batchId}`,
|
||||||
|
createdBy,
|
||||||
|
expiresAt,
|
||||||
|
usedBy: null,
|
||||||
|
usedAt: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 批量插入(drizzle-orm 支持 .values(array) 批量 INSERT)
|
||||||
|
await db.insert(invitationCodes).values(rows)
|
||||||
|
|
||||||
|
// MySQL 批量插入后无法直接返回所有行,按 code 查询
|
||||||
|
const codeList = codes
|
||||||
|
const [first, ...rest] = codeList
|
||||||
|
if (!first) {
|
||||||
|
return { codes: [], batchId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次查询取回所有刚插入的记录
|
||||||
|
// drizzle-orm 的 inArray 在 MySQL 下需要手动构造 OR
|
||||||
|
const records = await db
|
||||||
|
.select()
|
||||||
|
.from(invitationCodes)
|
||||||
|
.where(
|
||||||
|
rest.length === 0
|
||||||
|
? eq(invitationCodes.code, first)
|
||||||
|
: or(
|
||||||
|
eq(invitationCodes.code, first),
|
||||||
|
...rest.map((c) => eq(invitationCodes.code, c)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
codes: records.map(toRecord),
|
||||||
|
batchId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有邀请码(按创建时间倒序,audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* @param limit 最多返回的记录数(默认 100)
|
||||||
|
* @param includeUsed 是否包含已使用的邀请码(默认 true)
|
||||||
|
*/
|
||||||
|
export async function listInvitationCodes(
|
||||||
|
limit = 100,
|
||||||
|
includeUsed = true,
|
||||||
|
): Promise<InvitationCodeRecord[]> {
|
||||||
|
const condition = includeUsed ? undefined : isNull(invitationCodes.usedBy)
|
||||||
|
|
||||||
|
const rows = condition
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(invitationCodes)
|
||||||
|
.where(condition)
|
||||||
|
.orderBy(desc(invitationCodes.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(invitationCodes)
|
||||||
|
.orderBy(desc(invitationCodes.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map(toRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验邀请码是否可用(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 校验项:
|
||||||
|
* 1. 邀请码存在
|
||||||
|
* 2. 未被使用(usedBy 为 null)
|
||||||
|
* 3. 未过期(expiresAt 为 null 或在未来)
|
||||||
|
* 4. 邮箱匹配(若 email 字段非空,则使用者邮箱必须一致)
|
||||||
|
*
|
||||||
|
* 返回结果中 `code` 字段为脱敏数据(不含 code 明文),用于注册流程。
|
||||||
|
*/
|
||||||
|
export async function validateInvitationCode(
|
||||||
|
code: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<ValidateInvitationCodeResult> {
|
||||||
|
const normalizedCode = code.trim().toUpperCase()
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(invitationCodes)
|
||||||
|
.where(eq(invitationCodes.code, normalizedCode))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return { valid: false, reason: "not_found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.usedBy !== null) {
|
||||||
|
return { valid: false, reason: "already_used" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.expiresAt !== null && row.expiresAt.getTime() < Date.now()) {
|
||||||
|
return { valid: false, reason: "expired" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.email !== null && row.email.toLowerCase() !== email.toLowerCase()) {
|
||||||
|
return { valid: false, reason: "email_mismatch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脱敏:不返回 code 明文
|
||||||
|
const { code: _code, ...codeWithoutValue } = toRecord(row)
|
||||||
|
return { valid: true, code: codeWithoutValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记邀请码为已使用(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 使用乐观锁:仅在 usedBy IS NULL 时更新,避免并发使用同一邀请码。
|
||||||
|
* 返回 true 表示成功标记,false 表示邀请码已被他人使用。
|
||||||
|
*/
|
||||||
|
export async function consumeInvitationCode(
|
||||||
|
input: ConsumeInvitationCodeInput,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedCode = input.code.trim().toUpperCase()
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(invitationCodes)
|
||||||
|
.set({
|
||||||
|
usedBy: input.userId,
|
||||||
|
usedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitationCodes.code, normalizedCode),
|
||||||
|
isNull(invitationCodes.usedBy),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// MySQL ResultSetHeader affectedRows(drizzle-orm MySQL 返回 [ResultSetHeader, FieldPacket[]])
|
||||||
|
const rows = Array.isArray(result) ? result[0] : result
|
||||||
|
const affectedRows =
|
||||||
|
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||||
|
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||||
|
: 0
|
||||||
|
return affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除邀请码(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 仅允许删除未被使用的邀请码(已使用的邀请码保留作为审计记录)。
|
||||||
|
* 返回 true 表示删除成功,false 表示邀请码不存在或已被使用。
|
||||||
|
*/
|
||||||
|
export async function deleteInvitationCode(
|
||||||
|
codeId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.delete(invitationCodes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitationCodes.id, codeId),
|
||||||
|
isNull(invitationCodes.usedBy),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = Array.isArray(result) ? result[0] : result
|
||||||
|
const affectedRows =
|
||||||
|
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||||
|
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||||
|
: 0
|
||||||
|
return affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理已过期的邀请码(audit-P2-3 新增,可选维护函数)
|
||||||
|
*
|
||||||
|
* 删除已过期且未使用的邀请码。可由 cron 任务定期调用。
|
||||||
|
*/
|
||||||
|
export async function purgeExpiredInvitationCodes(): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.delete(invitationCodes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
lte(invitationCodes.expiresAt, new Date()),
|
||||||
|
isNull(invitationCodes.usedBy),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = Array.isArray(result) ? result[0] : result
|
||||||
|
const affectedRows =
|
||||||
|
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||||
|
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||||
|
: 0
|
||||||
|
return affectedRows
|
||||||
|
}
|
||||||
66
src/modules/invitation-codes/lib/code-generator.ts
Normal file
66
src/modules/invitation-codes/lib/code-generator.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 邀请码生成纯函数(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 纯函数,不依赖任何外部状态,便于单元测试。
|
||||||
|
* 生成格式:8 位大写字母 + 数字组合(去除易混淆字符 I/O/0/1)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto"
|
||||||
|
|
||||||
|
/** 安全字符集(去除 I/O/0/1 易混淆字符) */
|
||||||
|
const SAFE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
/** 邀请码长度 */
|
||||||
|
const CODE_LENGTH = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个邀请码
|
||||||
|
*
|
||||||
|
* 使用 node:crypto.randomBytes 作为随机源,避免 Math.random() 的伪随机性。
|
||||||
|
*/
|
||||||
|
export function generateInvitationCode(): string {
|
||||||
|
const bytes = randomBytes(CODE_LENGTH)
|
||||||
|
let code = ""
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||||
|
code += SAFE_ALPHABET[bytes[i] % SAFE_ALPHABET.length]
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成唯一邀请码
|
||||||
|
*
|
||||||
|
* 内部使用 Set 去重,确保返回的 codes 数组中无重复。
|
||||||
|
* 若因随机冲突无法生成足够唯一码(极端罕见),最多重试 3 次。
|
||||||
|
*/
|
||||||
|
export function generateUniqueInvitationCodes(count: number): string[] {
|
||||||
|
if (count < 1 || count > 1000) {
|
||||||
|
throw new Error(`count must be between 1 and 1000, got ${count}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = new Set<string>()
|
||||||
|
const maxAttempts = count * 3
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts && codes.size < count; attempt++) {
|
||||||
|
codes.add(generateInvitationCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.size < count) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate ${count} unique invitation codes after ${maxAttempts} attempts (only ${codes.size} unique)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成批次 ID(同一批生成的邀请码共享同一 batchId,便于追踪)
|
||||||
|
*
|
||||||
|
* 格式:`batch_<timestamp>_<random6>`
|
||||||
|
*/
|
||||||
|
export function generateBatchId(): string {
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = randomBytes(3).toString("hex")
|
||||||
|
return `batch_${timestamp}_${random}`
|
||||||
|
}
|
||||||
46
src/modules/invitation-codes/schema.ts
Normal file
46
src/modules/invitation-codes/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 邀请码 Zod 验证 schema(audit-P2-3 新增)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import type { InvitationRole } from "./types"
|
||||||
|
|
||||||
|
/** 邀请码角色枚举(与 types.ts InvitationRole 同步) */
|
||||||
|
export const INVITATION_ROLE_VALUES = [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"parent",
|
||||||
|
"admin",
|
||||||
|
] as const satisfies readonly InvitationRole[]
|
||||||
|
|
||||||
|
/** 批量生成邀请码 schema */
|
||||||
|
export const GenerateInvitationCodesSchema = z.object({
|
||||||
|
/** 生成数量:1-100 */
|
||||||
|
count: z.coerce.number().int().min(1).max(100),
|
||||||
|
role: z.enum(INVITATION_ROLE_VALUES),
|
||||||
|
/** 可选:限定邮箱 */
|
||||||
|
email: z.string().email().or(z.literal("")).optional(),
|
||||||
|
/** 可选:班级 ID */
|
||||||
|
classId: z.string().max(128).or(z.literal("")).optional(),
|
||||||
|
/** 可选:过期时间(ISO 字符串) */
|
||||||
|
expiresAt: z.string().datetime().or(z.literal("")).optional(),
|
||||||
|
/** 可选:备注 */
|
||||||
|
notes: z.string().max(500).or(z.literal("")).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GenerateInvitationCodesSchemaType = z.infer<
|
||||||
|
typeof GenerateInvitationCodesSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
/** 注册时校验邀请码 schema(嵌入 RegisterSchema) */
|
||||||
|
export const InvitationCodeFieldSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.min(8, "Invitation code must be at least 8 characters")
|
||||||
|
.max(64, "Invitation code too long")
|
||||||
|
.regex(
|
||||||
|
/^[A-Z0-9]+$/,
|
||||||
|
"Invitation code can only contain uppercase letters and numbers",
|
||||||
|
)
|
||||||
64
src/modules/invitation-codes/types.ts
Normal file
64
src/modules/invitation-codes/types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 邀请码模块类型定义(audit-P2-3 新增)
|
||||||
|
*
|
||||||
|
* 纯类型文件,无 `server-only`,可被 Server / Client Component 安全导入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 邀请码角色(与系统角色一致) */
|
||||||
|
export type InvitationRole = "student" | "teacher" | "parent" | "admin"
|
||||||
|
|
||||||
|
/** 邀请码记录(数据库行) */
|
||||||
|
export interface InvitationCodeRecord {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
email: string | null
|
||||||
|
role: InvitationRole
|
||||||
|
classId: string | null
|
||||||
|
notes: string | null
|
||||||
|
createdAt: Date
|
||||||
|
createdBy: string | null
|
||||||
|
expiresAt: Date | null
|
||||||
|
usedBy: string | null
|
||||||
|
usedAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量生成邀请码的输入 */
|
||||||
|
export interface GenerateInvitationCodesInput {
|
||||||
|
/** 生成数量(1-100) */
|
||||||
|
count: number
|
||||||
|
role: InvitationRole
|
||||||
|
/** 可选:限定邮箱(留空则任意邮箱可用) */
|
||||||
|
email?: string | null
|
||||||
|
/** 可选:自动加入的班级 ID */
|
||||||
|
classId?: string | null
|
||||||
|
/** 可选:过期时间(ISO 字符串或 Date) */
|
||||||
|
expiresAt?: Date | null
|
||||||
|
/** 可选:管理员备注 */
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量生成邀请码的结果 */
|
||||||
|
export interface GenerateInvitationCodesResult {
|
||||||
|
/** 生成的邀请码列表(含明文 code,仅此一次返回完整明文) */
|
||||||
|
codes: InvitationCodeRecord[]
|
||||||
|
/** 批次 ID(同一批生成的邀请码共享同一 batchId) */
|
||||||
|
batchId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验邀请码的结果 */
|
||||||
|
export interface ValidateInvitationCodeResult {
|
||||||
|
valid: boolean
|
||||||
|
/** 校验失败原因(valid=false 时有值) */
|
||||||
|
reason?: "not_found" | "already_used" | "expired" | "email_mismatch"
|
||||||
|
/** 校验通过时返回邀请码记录(脱敏:不含 code 明文) */
|
||||||
|
code?: Omit<InvitationCodeRecord, "code">
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用邀请码的输入 */
|
||||||
|
export interface ConsumeInvitationCodeInput {
|
||||||
|
code: string
|
||||||
|
/** 使用者邮箱(用于校验 email 限定) */
|
||||||
|
email: string
|
||||||
|
/** 使用者 userId(注册后写入 usedBy) */
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
257
src/modules/leave-requests/actions.ts
Normal file
257
src/modules/leave-requests/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
|
import { verifyParentChildRelation } from "@/modules/parent/data-access"
|
||||||
|
import { getStudentActiveClass } from "@/modules/classes/data-access"
|
||||||
|
import { syncExcusedFromLeaveRequest } from "@/modules/attendance/data-access"
|
||||||
|
|
||||||
|
import { CreateLeaveRequestSchema, ReviewLeaveRequestSchema } from "./schema"
|
||||||
|
import {
|
||||||
|
createLeaveRequest,
|
||||||
|
getLeaveRequest,
|
||||||
|
getLeaveRequests,
|
||||||
|
hasOverlappingLeave,
|
||||||
|
reviewLeaveRequest,
|
||||||
|
cancelLeaveRequest,
|
||||||
|
markAttendanceSynced,
|
||||||
|
} from "./data-access"
|
||||||
|
import type { LeaveType } from "./types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验提交人对学生的归属:
|
||||||
|
* - 家长视角:必须通过 verifyParentChildRelation 校验
|
||||||
|
* - 学生本人:requesterId === studentId(由调用方保证,本函数不处理)
|
||||||
|
* 返回值:true 表示是家长代提交且关系已校验;false 表示非家长路径(学生本人)。
|
||||||
|
*/
|
||||||
|
async function assertRequesterRelation(
|
||||||
|
studentId: string,
|
||||||
|
requesterId: string,
|
||||||
|
): Promise<{ isParent: boolean; ok: boolean }> {
|
||||||
|
if (studentId === requesterId) {
|
||||||
|
return { isParent: false, ok: true }
|
||||||
|
}
|
||||||
|
const relation = await verifyParentChildRelation(studentId, requesterId)
|
||||||
|
return { isParent: true, ok: relation !== null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建请假申请(家长代子女 / 学生本人)。 */
|
||||||
|
export async function createLeaveRequestAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const t = await getTranslations("leave")
|
||||||
|
const ctx = await requirePermission(Permissions.LEAVE_REQUEST_CREATE)
|
||||||
|
|
||||||
|
const parsed = CreateLeaveRequestSchema.safeParse({
|
||||||
|
studentId: formData.get("studentId"),
|
||||||
|
classId: formData.get("classId"),
|
||||||
|
leaveType: formData.get("leaveType"),
|
||||||
|
startDate: formData.get("startDate"),
|
||||||
|
endDate: formData.get("endDate"),
|
||||||
|
reason: formData.get("reason"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: t("errors.invalidForm"),
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验提交人与学生的归属关系
|
||||||
|
const relation = await assertRequesterRelation(parsed.data.studentId, ctx.userId)
|
||||||
|
if (!relation.ok) {
|
||||||
|
return { success: false, message: t("errors.noRelation") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 classId 确为学生当前所在班级(防止伪造班级 ID)
|
||||||
|
const activeClass = await getStudentActiveClass(parsed.data.studentId)
|
||||||
|
if (!activeClass || activeClass.classId !== parsed.data.classId) {
|
||||||
|
return { success: false, message: t("errors.classMismatch") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验日期范围内无未结束的请假
|
||||||
|
const hasOverlap = await hasOverlappingLeave(
|
||||||
|
parsed.data.studentId,
|
||||||
|
parsed.data.startDate,
|
||||||
|
parsed.data.endDate,
|
||||||
|
)
|
||||||
|
if (hasOverlap) {
|
||||||
|
return { success: false, message: t("errors.overlappingLeave") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await createLeaveRequest(parsed.data, ctx.userId)
|
||||||
|
revalidatePath("/parent/leave")
|
||||||
|
revalidatePath("/student/leave")
|
||||||
|
revalidatePath("/teacher/leave")
|
||||||
|
await trackEvent({
|
||||||
|
event: "leave_request.created",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "leave_request",
|
||||||
|
properties: {
|
||||||
|
studentId: parsed.data.studentId,
|
||||||
|
classId: parsed.data.classId,
|
||||||
|
leaveType: parsed.data.leaveType,
|
||||||
|
startDate: parsed.data.startDate,
|
||||||
|
endDate: parsed.data.endDate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { success: true, message: t("messages.created"), data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批请假申请(班主任 / 管理员)。 */
|
||||||
|
export async function reviewLeaveRequestAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const t = await getTranslations("leave")
|
||||||
|
const ctx = await requirePermission(Permissions.LEAVE_REQUEST_REVIEW)
|
||||||
|
|
||||||
|
const parsed = ReviewLeaveRequestSchema.safeParse({
|
||||||
|
status: formData.get("status"),
|
||||||
|
reviewComment: formData.get("reviewComment") || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: t("errors.invalidForm"),
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拒绝时审批意见必填
|
||||||
|
if (parsed.data.status === "rejected" && !parsed.data.reviewComment?.trim()) {
|
||||||
|
return { success: false, message: t("errors.reviewCommentRequired") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验审批人对该请假所在班级的归属
|
||||||
|
const target = await getLeaveRequest(id, ctx.dataScope, ctx.userId)
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, message: t("errors.notFound") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// class_taught scope 下,data-access 已通过 buildScopeFilter 过滤;admin 直接放行
|
||||||
|
// 但仍需再次确认 target.status === 'pending'(reviewLeaveRequest 内部也会校验)
|
||||||
|
if (target.status !== "pending") {
|
||||||
|
return { success: false, message: t("errors.alreadyReviewed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await reviewLeaveRequest(id, parsed.data, ctx.userId)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: t("errors.alreadyReviewed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审批通过后异步同步考勤(写入 excused 记录)
|
||||||
|
if (updated.status === "approved" && !updated.attendanceSynced) {
|
||||||
|
try {
|
||||||
|
const leaveTypeLabel = leaveTypeReasonLabel(updated.leaveType)
|
||||||
|
await syncExcusedFromLeaveRequest({
|
||||||
|
studentId: updated.studentId,
|
||||||
|
classId: updated.classId,
|
||||||
|
startDate: updated.startDate,
|
||||||
|
endDate: updated.endDate,
|
||||||
|
reason: `请假:${leaveTypeLabel}(${updated.reason.slice(0, 80)})`,
|
||||||
|
reviewerId: ctx.userId,
|
||||||
|
})
|
||||||
|
await markAttendanceSynced(id)
|
||||||
|
} catch {
|
||||||
|
// 考勤同步失败不阻断审批结果(审批已成功,考勤可由管理员后续手动补录)
|
||||||
|
// 不抛出错误,但也不标记 attendanceSynced,便于后续重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/teacher/leave")
|
||||||
|
revalidatePath("/parent/leave")
|
||||||
|
revalidatePath(`/teacher/leave/${id}`)
|
||||||
|
await trackEvent({
|
||||||
|
event: "leave_request.reviewed",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "leave_request",
|
||||||
|
properties: { status: parsed.data.status },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
parsed.data.status === "approved"
|
||||||
|
? t("messages.approved")
|
||||||
|
: t("messages.rejected"),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销请假申请(仅 pending 状态、提交人本人可撤销)。 */
|
||||||
|
export async function cancelLeaveRequestAction(
|
||||||
|
id: string,
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const t = await getTranslations("leave")
|
||||||
|
const ctx = await requirePermission(Permissions.LEAVE_REQUEST_CREATE)
|
||||||
|
|
||||||
|
const target = await getLeaveRequest(id, ctx.dataScope, ctx.userId)
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, message: t("errors.notFound") }
|
||||||
|
}
|
||||||
|
if (target.requesterId !== ctx.userId) {
|
||||||
|
return { success: false, message: t("errors.notOwner") }
|
||||||
|
}
|
||||||
|
if (target.status !== "pending") {
|
||||||
|
return { success: false, message: t("errors.cannotCancel") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await cancelLeaveRequest(id, ctx.userId)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: t("errors.cannotCancel") }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/parent/leave")
|
||||||
|
revalidatePath("/student/leave")
|
||||||
|
revalidatePath("/teacher/leave")
|
||||||
|
await trackEvent({
|
||||||
|
event: "leave_request.cancelled",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetId: id,
|
||||||
|
targetType: "leave_request",
|
||||||
|
})
|
||||||
|
return { success: true, message: t("messages.cancelled") }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假类型中文标签(用于考勤同步 reason 文本,服务端无 i18n 上下文)。 */
|
||||||
|
function leaveTypeReasonLabel(type: LeaveType): string {
|
||||||
|
const labels: Record<LeaveType, string> = {
|
||||||
|
sick: "病假",
|
||||||
|
personal: "事假",
|
||||||
|
family: "家庭事务",
|
||||||
|
other: "其他",
|
||||||
|
}
|
||||||
|
return labels[type] ?? type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内部查询辅助:供页面 Server Component 使用(包装 dataScope)。 */
|
||||||
|
export async function listMyLeaveRequests(
|
||||||
|
scope: Parameters<typeof getLeaveRequests>[0]["scope"],
|
||||||
|
currentUserId: string,
|
||||||
|
status?: Parameters<typeof getLeaveRequests>[0]["status"],
|
||||||
|
): ReturnType<typeof getLeaveRequests> {
|
||||||
|
return getLeaveRequests({ scope, currentUserId, status, page: 1, pageSize: 50 })
|
||||||
|
}
|
||||||
195
src/modules/leave-requests/components/leave-request-form.tsx
Normal file
195
src/modules/leave-requests/components/leave-request-form.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from "react"
|
||||||
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Loader2, Send } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
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 { createLeaveRequestAction } from "../actions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
/** 子女选项(家长视角下选择为哪个孩子请假;学生视角可省略本字段) */
|
||||||
|
export interface ChildOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
const t = useTranslations("leave")
|
||||||
|
return (
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("form.submitting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{t("form.submit")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请假申请表单。
|
||||||
|
*
|
||||||
|
* - 家长视角:childOptions 数组非空,下拉选择子女
|
||||||
|
* - 学生视角:childOptions 省略,使用传入的 defaultStudentId/classId
|
||||||
|
*
|
||||||
|
* 通过 useActionState 调用 createLeaveRequestAction,提交成功后 toast 提示并调用 onSuccess。
|
||||||
|
*
|
||||||
|
* 注意:prop 名为 `childOptions` 而非 `children`,避免与 React 特殊 children prop 冲突
|
||||||
|
* 触发 ESLint `react/no-children-prop` 规则。
|
||||||
|
*/
|
||||||
|
export function LeaveRequestForm({
|
||||||
|
childOptions,
|
||||||
|
defaultStudentId,
|
||||||
|
defaultClassId,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
childOptions?: ChildOption[]
|
||||||
|
defaultStudentId?: string
|
||||||
|
defaultClassId?: string
|
||||||
|
onSuccess?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("leave")
|
||||||
|
const [state, formAction] = useActionState<ActionState<string>, FormData>(
|
||||||
|
createLeaveRequestAction,
|
||||||
|
{ success: false },
|
||||||
|
)
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
toast.success(state.message)
|
||||||
|
formRef.current?.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
} else if (state.message) {
|
||||||
|
toast.error(state.message)
|
||||||
|
}
|
||||||
|
}, [state, onSuccess])
|
||||||
|
|
||||||
|
const isParentMode = (childOptions?.length ?? 0) > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} action={formAction} className="space-y-4">
|
||||||
|
{isParentMode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="studentId">{t("form.student")}</Label>
|
||||||
|
<Select name="studentId" defaultValue={defaultStudentId ?? childOptions?.[0]?.id}>
|
||||||
|
<SelectTrigger id="studentId">
|
||||||
|
<SelectValue placeholder={t("form.studentPlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{childOptions?.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}({c.className})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state.errors?.studentId?.[0] && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.studentId[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isParentMode && defaultStudentId && defaultClassId && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="studentId" value={defaultStudentId} />
|
||||||
|
<input type="hidden" name="classId" value={defaultClassId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* 家长模式下 classId 通过子组件映射动态写入;学生模式已 hidden 写入 */}
|
||||||
|
{isParentMode && (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="classId"
|
||||||
|
value={childOptions?.find((c) => c.id === defaultStudentId)?.classId ?? childOptions?.[0]?.classId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="leaveType">{t("form.leaveType")}</Label>
|
||||||
|
<Select name="leaveType" defaultValue="sick">
|
||||||
|
<SelectTrigger id="leaveType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sick">{t("types.sick")}</SelectItem>
|
||||||
|
<SelectItem value="personal">{t("types.personal")}</SelectItem>
|
||||||
|
<SelectItem value="family">{t("types.family")}</SelectItem>
|
||||||
|
<SelectItem value="other">{t("types.other")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state.errors?.leaveType?.[0] && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.leaveType[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dateRange">{t("form.dateRange")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state.errors?.startDate?.[0] && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.startDate[0]}</p>
|
||||||
|
)}
|
||||||
|
{state.errors?.endDate?.[0] && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.endDate[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reason">{t("form.reason")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reason"
|
||||||
|
name="reason"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder={t("form.reasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
{state.errors?.reason?.[0] && (
|
||||||
|
<p className="text-sm text-destructive">{state.errors.reason[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<SubmitButton />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/modules/leave-requests/components/leave-request-list.tsx
Normal file
117
src/modules/leave-requests/components/leave-request-list.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { CalendarDays, CheckCircle2, XCircle, Clock, Ban } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import type { LeaveRequestListItem, LeaveStatus, LeaveType } from "../types"
|
||||||
|
|
||||||
|
type IconKey = "clock" | "check" | "x" | "ban"
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<LeaveStatus, { variant: "default" | "secondary" | "destructive" | "outline"; iconKey: IconKey }> = {
|
||||||
|
pending: { variant: "secondary", iconKey: "clock" },
|
||||||
|
approved: { variant: "default", iconKey: "check" },
|
||||||
|
rejected: { variant: "destructive", iconKey: "x" },
|
||||||
|
cancelled: { variant: "outline", iconKey: "ban" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICONS: Record<IconKey, typeof Clock> = {
|
||||||
|
clock: Clock,
|
||||||
|
check: CheckCircle2,
|
||||||
|
x: XCircle,
|
||||||
|
ban: Ban,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请假申请列表(只读展示)。
|
||||||
|
*
|
||||||
|
* 适用于家长/学生查看自己的请假记录,教师查看本班请假(审批操作走 LeaveReviewDialog)。
|
||||||
|
*/
|
||||||
|
export function LeaveRequestList({
|
||||||
|
items,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: {
|
||||||
|
items: LeaveRequestListItem[]
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDescription: string
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("leave")
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={CalendarDays}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const meta = STATUS_STYLES[item.status]
|
||||||
|
const Icon = STATUS_ICONS[meta.iconKey]
|
||||||
|
return (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<span>{item.studentName}</span>
|
||||||
|
<span className="text-muted-foreground font-normal">·</span>
|
||||||
|
<span className="text-muted-foreground font-normal">{item.className}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("types." + item.leaveType)} · {item.startDate} ~ {item.endDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={meta.variant}>
|
||||||
|
{t("status." + item.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("list.reason")}:</span>
|
||||||
|
<span>{item.reason}</span>
|
||||||
|
</div>
|
||||||
|
{item.reviewerName && item.reviewedAt && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("list.reviewedBy", { name: item.reviewerName, date: item.reviewedAt.slice(0, 10) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.reviewComment && (
|
||||||
|
<div className="rounded-md bg-muted/50 p-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("list.reviewComment")}:</span>
|
||||||
|
<span>{item.reviewComment}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.attendanceSynced && (
|
||||||
|
<div className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t("list.attendanceSynced")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假类型中文标签(复用工具函数,避免重复定义)。 */
|
||||||
|
export function leaveTypeLabel(type: LeaveType): string {
|
||||||
|
const labels: Record<LeaveType, string> = {
|
||||||
|
sick: "病假",
|
||||||
|
personal: "事假",
|
||||||
|
family: "家庭事务",
|
||||||
|
other: "其他",
|
||||||
|
}
|
||||||
|
return labels[type] ?? type
|
||||||
|
}
|
||||||
161
src/modules/leave-requests/components/leave-review-dialog.tsx
Normal file
161
src/modules/leave-requests/components/leave-review-dialog.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef, useState } from "react"
|
||||||
|
import { useFormStatus } from "react-dom"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Check, Loader2, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
import { reviewLeaveRequestAction } from "../actions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import type { LeaveRequestListItem } from "../types"
|
||||||
|
|
||||||
|
function ReviewSubmitButton({ label }: { label: string }) {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
return (
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请假审批对话框。
|
||||||
|
*
|
||||||
|
* 教师在审批列表中点击「批准」/「拒绝」按钮打开此对话框。
|
||||||
|
* 拒绝时审批意见必填;批准时可选。
|
||||||
|
* 提交成功后关闭对话框并调用 onReviewed 回调(父组件刷新列表)。
|
||||||
|
*/
|
||||||
|
export function LeaveReviewDialog({
|
||||||
|
leaveRequest,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onReviewed,
|
||||||
|
}: {
|
||||||
|
leaveRequest: LeaveRequestListItem | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onReviewed?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("leave")
|
||||||
|
const [decision, setDecision] = useState<"approved" | "rejected">("approved")
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
|
||||||
|
const reviewActionWithId = async (
|
||||||
|
_prevState: ActionState<string> | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ActionState<string>> => {
|
||||||
|
if (!leaveRequest) {
|
||||||
|
return { success: false, message: t("errors.notFound") }
|
||||||
|
}
|
||||||
|
formData.set("status", decision)
|
||||||
|
return reviewLeaveRequestAction(leaveRequest.id, _prevState, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, formAction] = useActionState<ActionState<string>, FormData>(
|
||||||
|
reviewActionWithId,
|
||||||
|
{ success: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
toast.success(state.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
formRef.current?.reset()
|
||||||
|
onReviewed?.()
|
||||||
|
} else if (state.message) {
|
||||||
|
toast.error(state.message)
|
||||||
|
}
|
||||||
|
}, [state, onOpenChange, onReviewed])
|
||||||
|
|
||||||
|
if (!leaveRequest) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("review.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("review.description", {
|
||||||
|
name: leaveRequest.studentName,
|
||||||
|
className: leaveRequest.className,
|
||||||
|
dateRange: `${leaveRequest.startDate} ~ ${leaveRequest.endDate}`,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted/40 p-3 text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("list.reason")}:</span>
|
||||||
|
<span>{leaveRequest.reason}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("form.leaveType")}:</span>
|
||||||
|
<span>{t("types." + leaveRequest.leaveType)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} action={formAction} className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={decision === "approved" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDecision("approved")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
{t("review.approve")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={decision === "rejected" ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDecision("rejected")}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
{t("review.reject")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reviewComment">
|
||||||
|
{t("review.commentLabel")}
|
||||||
|
{decision === "rejected" && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reviewComment"
|
||||||
|
name="reviewComment"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder={t("review.commentPlaceholder")}
|
||||||
|
required={decision === "rejected"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("review.cancel")}
|
||||||
|
</Button>
|
||||||
|
<ReviewSubmitButton
|
||||||
|
label={decision === "approved" ? t("review.approve") : t("review.reject")}
|
||||||
|
/>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/modules/leave-requests/components/leave-review-list.tsx
Normal file
118
src/modules/leave-requests/components/leave-review-list.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { CalendarDays } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import type { LeaveRequestListItem } from "../types"
|
||||||
|
import { LeaveReviewDialog } from "./leave-review-dialog"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师审批列表(带审批按钮)。
|
||||||
|
*
|
||||||
|
* 列表展示待审批/已审批的请假申请;点击「审批」按钮打开对话框。
|
||||||
|
* 审批成功后调用 onReviewed 回调(父组件刷新列表)。
|
||||||
|
*/
|
||||||
|
export function LeaveReviewList({
|
||||||
|
items,
|
||||||
|
onReviewed,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: {
|
||||||
|
items: LeaveRequestListItem[]
|
||||||
|
onReviewed?: () => void
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDescription: string
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("leave")
|
||||||
|
const [selected, setSelected] = useState<LeaveRequestListItem | null>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={CalendarDays}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReviewClick = (item: LeaveRequestListItem) => {
|
||||||
|
if (item.status !== "pending") return
|
||||||
|
setSelected(item)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isPending = item.status === "pending"
|
||||||
|
return (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{item.studentName}
|
||||||
|
<span className="ml-2 text-muted-foreground font-normal">·</span>
|
||||||
|
<span className="ml-2 text-muted-foreground font-normal">{item.className}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("types." + item.leaveType)} · {item.startDate} ~ {item.endDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={isPending ? "secondary" : "outline"}>
|
||||||
|
{t("status." + item.status)}
|
||||||
|
</Badge>
|
||||||
|
{isPending && (
|
||||||
|
<Button size="sm" variant="default" onClick={() => handleReviewClick(item)}>
|
||||||
|
{t("review.openButton")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("list.reason")}:</span>
|
||||||
|
<span>{item.reason}</span>
|
||||||
|
</div>
|
||||||
|
{item.reviewerName && item.reviewedAt && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("list.reviewedBy", { name: item.reviewerName, date: item.reviewedAt.slice(0, 10) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.reviewComment && (
|
||||||
|
<div className="rounded-md bg-muted/50 p-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("list.reviewComment")}:</span>
|
||||||
|
<span>{item.reviewComment}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.attendanceSynced && (
|
||||||
|
<div className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t("list.attendanceSynced")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeaveReviewDialog
|
||||||
|
leaveRequest={selected}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onReviewed={onReviewed}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
333
src/modules/leave-requests/data-access.ts
Normal file
333
src/modules/leave-requests/data-access.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { leaveRequests } from "@/shared/db/schema"
|
||||||
|
import { getClassNamesByIds } from "@/modules/classes/data-access"
|
||||||
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LeaveRequest,
|
||||||
|
LeaveRequestListItem,
|
||||||
|
LeaveStatus,
|
||||||
|
LeaveType,
|
||||||
|
PaginatedLeaveResult,
|
||||||
|
LeaveQueryParams,
|
||||||
|
} from "./types"
|
||||||
|
import type { CreateLeaveRequestInput, ReviewLeaveRequestInput } from "./schema"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 dataScope 过滤条件。
|
||||||
|
* - all:无过滤
|
||||||
|
* - class_taught:仅所辖班级
|
||||||
|
* - children:仅子女
|
||||||
|
* - class_members:仅本人
|
||||||
|
* - owned:仅本人提交
|
||||||
|
* - grade_managed:暂不支持(返回 1=0)
|
||||||
|
*/
|
||||||
|
function buildScopeFilter(scope: DataScope): SQL | null {
|
||||||
|
if (scope.type === "all") return null
|
||||||
|
if (scope.type === "class_taught") {
|
||||||
|
return scope.classIds.length > 0
|
||||||
|
? inArray(leaveRequests.classId, scope.classIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "children") {
|
||||||
|
return scope.childrenIds.length > 0
|
||||||
|
? inArray(leaveRequests.studentId, scope.childrenIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "class_members") {
|
||||||
|
// 学生只看自己的请假
|
||||||
|
return sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "owned") {
|
||||||
|
return eq(leaveRequests.requesterId, scope.userId)
|
||||||
|
}
|
||||||
|
return sql`1=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeDate = (d: Date | string | null): string =>
|
||||||
|
d ? new Date(d).toISOString().slice(0, 10) : ""
|
||||||
|
|
||||||
|
function serializeRequest(row: typeof leaveRequests.$inferSelect): LeaveRequest {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
studentId: row.studentId,
|
||||||
|
requesterId: row.requesterId,
|
||||||
|
classId: row.classId,
|
||||||
|
leaveType: row.leaveType as LeaveType,
|
||||||
|
startDate: serializeDate(row.startDate),
|
||||||
|
endDate: serializeDate(row.endDate),
|
||||||
|
reason: row.reason,
|
||||||
|
status: row.status as LeaveStatus,
|
||||||
|
reviewerId: row.reviewerId ?? null,
|
||||||
|
reviewComment: row.reviewComment ?? null,
|
||||||
|
reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,
|
||||||
|
attendanceSynced: row.attendanceSynced,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichListItems(
|
||||||
|
rows: typeof leaveRequests.$inferSelect[],
|
||||||
|
currentUserId: string,
|
||||||
|
): Promise<LeaveRequestListItem[]> {
|
||||||
|
if (rows.length === 0) return []
|
||||||
|
|
||||||
|
const studentIds = Array.from(new Set(rows.map((r) => r.studentId)))
|
||||||
|
const requesterIds = Array.from(new Set(rows.map((r) => r.requesterId)))
|
||||||
|
const reviewerIds = Array.from(
|
||||||
|
new Set(rows.map((r) => r.reviewerId).filter((id): id is string => id !== null)),
|
||||||
|
)
|
||||||
|
const classIds = Array.from(new Set(rows.map((r) => r.classId)))
|
||||||
|
|
||||||
|
const [studentMap, requesterMap, reviewerMap, classMap] = await Promise.all([
|
||||||
|
getUserNamesByIds(studentIds),
|
||||||
|
getUserNamesByIds(requesterIds),
|
||||||
|
reviewerIds.length > 0 ? getUserNamesByIds(reviewerIds) : Promise.resolve(new Map()),
|
||||||
|
getClassNamesByIds(classIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 学生视角下,class_members 的 currentUserId 是学生本人
|
||||||
|
void currentUserId
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const base = serializeRequest(row)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
studentName: studentMap.get(row.studentId)?.name ?? "Unknown",
|
||||||
|
className: classMap.get(row.classId) ?? "Unknown",
|
||||||
|
requesterName: requesterMap.get(row.requesterId)?.name ?? "Unknown",
|
||||||
|
reviewerName: row.reviewerId ? reviewerMap.get(row.reviewerId)?.name ?? null : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询请假申请列表(按 dataScope 过滤)。
|
||||||
|
*/
|
||||||
|
export async function getLeaveRequests(
|
||||||
|
params: LeaveQueryParams & { scope: DataScope; currentUserId?: string },
|
||||||
|
): Promise<PaginatedLeaveResult> {
|
||||||
|
const page = Math.max(1, params.page ?? 1)
|
||||||
|
const pageSize = Math.max(1, Math.min(50, params.pageSize ?? 20))
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
|
||||||
|
const scopeFilter = buildScopeFilter(params.scope)
|
||||||
|
if (scopeFilter) conditions.push(scopeFilter)
|
||||||
|
|
||||||
|
// class_members 由调用方传入 currentUserId 时,过滤为本人提交
|
||||||
|
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||||
|
conditions.push(eq(leaveRequests.requesterId, params.currentUserId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.classId) conditions.push(eq(leaveRequests.classId, params.classId))
|
||||||
|
if (params.studentId) conditions.push(eq(leaveRequests.studentId, params.studentId))
|
||||||
|
if (params.status) conditions.push(eq(leaveRequests.status, params.status))
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
|
const [totalRow, rows] = await Promise.all([
|
||||||
|
db.select({ count: sql<number>`count(*)` }).from(leaveRequests).where(where),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(leaveRequests.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = Number(totalRow[0]?.count ?? 0)
|
||||||
|
const items = await enrichListItems(rows, params.currentUserId ?? "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单条请假申请详情(按 dataScope 校验访问权限)。
|
||||||
|
*/
|
||||||
|
export async function getLeaveRequest(
|
||||||
|
id: string,
|
||||||
|
scope: DataScope,
|
||||||
|
currentUserId?: string,
|
||||||
|
): Promise<LeaveRequestListItem | null> {
|
||||||
|
const conditions: SQL[] = [eq(leaveRequests.id, id)]
|
||||||
|
const scopeFilter = buildScopeFilter(scope)
|
||||||
|
if (scopeFilter) conditions.push(scopeFilter)
|
||||||
|
if (scope.type === "class_members" && currentUserId) {
|
||||||
|
conditions.push(eq(leaveRequests.requesterId, currentUserId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) return null
|
||||||
|
const items = await enrichListItems([row], currentUserId ?? "")
|
||||||
|
return items[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验同一学生在日期范围内是否已有未结束的请假(pending/approved)。
|
||||||
|
* 用于提交前校验,避免重复请假。
|
||||||
|
*/
|
||||||
|
export async function hasOverlappingLeave(
|
||||||
|
studentId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
excludeId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(leaveRequests.studentId, studentId),
|
||||||
|
inArray(leaveRequests.status, ["pending", "approved"]),
|
||||||
|
sql`${leaveRequests.startDate} <= ${endDate}`,
|
||||||
|
sql`${leaveRequests.endDate} >= ${startDate}`,
|
||||||
|
]
|
||||||
|
if (excludeId) conditions.push(eq(leaveRequests.id, excludeId))
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: leaveRequests.id })
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1)
|
||||||
|
return existing.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建请假申请。
|
||||||
|
* 调用方需先校验:1) requester 与 student 关系(家长-子女 或 学生本人);
|
||||||
|
* 2) classId 确为学生当前所在班级;3) 无日期重叠的未结束请假。
|
||||||
|
*/
|
||||||
|
export async function createLeaveRequest(
|
||||||
|
data: CreateLeaveRequestInput,
|
||||||
|
requesterId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(leaveRequests).values({
|
||||||
|
id,
|
||||||
|
studentId: data.studentId,
|
||||||
|
requesterId,
|
||||||
|
classId: data.classId,
|
||||||
|
leaveType: data.leaveType,
|
||||||
|
startDate: new Date(data.startDate),
|
||||||
|
endDate: new Date(data.endDate),
|
||||||
|
reason: data.reason,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批请假申请(pending → approved/rejected)。
|
||||||
|
* 调用方需先校验审批人对该班级的归属(class_taught / all)。
|
||||||
|
*/
|
||||||
|
export async function reviewLeaveRequest(
|
||||||
|
id: string,
|
||||||
|
review: ReviewLeaveRequestInput,
|
||||||
|
reviewerId: string,
|
||||||
|
): Promise<LeaveRequest | null> {
|
||||||
|
const result = await db
|
||||||
|
.update(leaveRequests)
|
||||||
|
.set({
|
||||||
|
status: review.status,
|
||||||
|
reviewerId,
|
||||||
|
reviewComment: review.reviewComment ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(leaveRequests.id, id), eq(leaveRequests.status, "pending")))
|
||||||
|
|
||||||
|
if (result[0].affectedRows === 0) return null
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(eq(leaveRequests.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return null
|
||||||
|
return serializeRequest(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销请假申请(仅 pending 状态可撤销,且只有 requester 本人可撤销)。
|
||||||
|
*/
|
||||||
|
export async function cancelLeaveRequest(
|
||||||
|
id: string,
|
||||||
|
requesterId: string,
|
||||||
|
): Promise<LeaveRequest | null> {
|
||||||
|
const result = await db
|
||||||
|
.update(leaveRequests)
|
||||||
|
.set({ status: "cancelled" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(leaveRequests.id, id),
|
||||||
|
eq(leaveRequests.requesterId, requesterId),
|
||||||
|
eq(leaveRequests.status, "pending"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result[0].affectedRows === 0) return null
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(eq(leaveRequests.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return null
|
||||||
|
return serializeRequest(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L-5 审批通过后,标记考勤已同步。
|
||||||
|
* 由考勤同步逻辑调用,确保不重复同步。
|
||||||
|
*/
|
||||||
|
export async function markAttendanceSynced(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(leaveRequests)
|
||||||
|
.set({ attendanceSynced: true })
|
||||||
|
.where(and(eq(leaveRequests.id, id), eq(leaveRequests.attendanceSynced, false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定状态下的请假申请(用于审批通过后的考勤同步)。
|
||||||
|
*/
|
||||||
|
export async function getLeaveRequestsByIds(
|
||||||
|
ids: string[],
|
||||||
|
): Promise<LeaveRequest[]> {
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(inArray(leaveRequests.id, ids))
|
||||||
|
.orderBy(asc(leaveRequests.startDate))
|
||||||
|
return rows.map(serializeRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师审批待办:按 classIds 统计 pending 数量。
|
||||||
|
*/
|
||||||
|
export async function countPendingForClasses(
|
||||||
|
classIds: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (classIds.length === 0) return 0
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(leaveRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(leaveRequests.classId, classIds),
|
||||||
|
eq(leaveRequests.status, "pending"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Number(row?.count ?? 0)
|
||||||
|
}
|
||||||
35
src/modules/leave-requests/schema.ts
Normal file
35
src/modules/leave-requests/schema.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const LeaveTypeEnum = z.enum(["sick", "personal", "family", "other"])
|
||||||
|
|
||||||
|
export const LeaveStatusEnum = z.enum([
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"rejected",
|
||||||
|
"cancelled",
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 创建请假申请 Schema */
|
||||||
|
export const CreateLeaveRequestSchema = z
|
||||||
|
.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
classId: z.string().min(1),
|
||||||
|
leaveType: LeaveTypeEnum,
|
||||||
|
startDate: z.string().min(1),
|
||||||
|
endDate: z.string().min(1),
|
||||||
|
reason: z.string().min(1).max(500),
|
||||||
|
})
|
||||||
|
.refine((data) => data.endDate >= data.startDate, {
|
||||||
|
message: "endDate must be on or after startDate",
|
||||||
|
path: ["endDate"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateLeaveRequestInput = z.infer<typeof CreateLeaveRequestSchema>
|
||||||
|
|
||||||
|
/** 审批请假申请 Schema */
|
||||||
|
export const ReviewLeaveRequestSchema = z.object({
|
||||||
|
status: z.enum(["approved", "rejected"]),
|
||||||
|
reviewComment: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ReviewLeaveRequestInput = z.infer<typeof ReviewLeaveRequestSchema>
|
||||||
63
src/modules/leave-requests/types.ts
Normal file
63
src/modules/leave-requests/types.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* L-5 在线请假流程类型定义。
|
||||||
|
*
|
||||||
|
* 状态机:pending → approved/rejected;pending/approved 可被 requester 撤销为 cancelled。
|
||||||
|
* 审批通过后异步同步考勤(写入 attendance_records with status='excused')。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 请假类型 */
|
||||||
|
export type LeaveType = "sick" | "personal" | "family" | "other"
|
||||||
|
|
||||||
|
/** 请假状态 */
|
||||||
|
export type LeaveStatus = "pending" | "approved" | "rejected" | "cancelled"
|
||||||
|
|
||||||
|
/** 请假申请完整记录(对应 leave_requests 表的展示形态) */
|
||||||
|
export interface LeaveRequest {
|
||||||
|
id: string
|
||||||
|
studentId: string
|
||||||
|
requesterId: string
|
||||||
|
classId: string
|
||||||
|
leaveType: LeaveType
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
reason: string
|
||||||
|
status: LeaveStatus
|
||||||
|
reviewerId: string | null
|
||||||
|
reviewComment: string | null
|
||||||
|
reviewedAt: string | null
|
||||||
|
attendanceSynced: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假列表项(带学生/班级/提交人姓名,便于列表渲染) */
|
||||||
|
export interface LeaveRequestListItem extends LeaveRequest {
|
||||||
|
studentName: string
|
||||||
|
className: string
|
||||||
|
requesterName: string
|
||||||
|
reviewerName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假审批操作输入 */
|
||||||
|
export interface LeaveReviewInput {
|
||||||
|
status: "approved" | "rejected"
|
||||||
|
reviewComment?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假查询参数 */
|
||||||
|
export interface LeaveQueryParams {
|
||||||
|
status?: LeaveStatus
|
||||||
|
classId?: string
|
||||||
|
studentId?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页结果 */
|
||||||
|
export interface PaginatedLeaveResult {
|
||||||
|
items: LeaveRequestListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
222
src/modules/standards/actions.ts
Normal file
222
src/modules/standards/actions.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* M1 课标(Standards)模块 - Server Actions
|
||||||
|
*
|
||||||
|
* 所有 Action 必须调用 requirePermission() 进行权限校验。
|
||||||
|
* 返回值统一采用 ActionState<T> 类型。
|
||||||
|
*/
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
createStandardSchema,
|
||||||
|
updateStandardSchema,
|
||||||
|
getStandardsParamsSchema,
|
||||||
|
linkPlanToStandardSchema,
|
||||||
|
} from "./schema";
|
||||||
|
import {
|
||||||
|
getStandards,
|
||||||
|
getStandardsTree,
|
||||||
|
getStandardById,
|
||||||
|
createStandard,
|
||||||
|
updateStandard,
|
||||||
|
deactivateStandard,
|
||||||
|
searchStandards,
|
||||||
|
getStandardsByPlanId,
|
||||||
|
linkPlanToStandard,
|
||||||
|
unlinkPlanFromStandard,
|
||||||
|
} from "./data-access";
|
||||||
|
import type { Standard, StandardTreeNode, LessonPlanStandardLink } from "./types";
|
||||||
|
import type { ActionState } from "@/shared/types/action-state";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
|
import { safeParseWithI18n, translateFieldErrors } from "@/modules/lesson-preparation/lib/i18n-errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课标列表
|
||||||
|
*/
|
||||||
|
export async function getStandardsAction(
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ items: Standard[]; tree?: StandardTreeNode[] }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_READ);
|
||||||
|
const t = await getTranslations("standards");
|
||||||
|
const parseResult = getStandardsParamsSchema.safeParse(params ?? {});
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: t("error.invalidParams"),
|
||||||
|
errors: await translateFieldErrors(parseResult.error.flatten().fieldErrors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asTree, ...queryParams } = parseResult.data;
|
||||||
|
if (asTree) {
|
||||||
|
const tree = await getStandardsTree(queryParams);
|
||||||
|
return { success: true, data: { items: [], tree } };
|
||||||
|
}
|
||||||
|
const items = await getStandards(queryParams);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个课标
|
||||||
|
*/
|
||||||
|
export async function getStandardByIdAction(
|
||||||
|
id: string,
|
||||||
|
): Promise<ActionState<{ standard: Standard }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_READ);
|
||||||
|
const standard = await getStandardById(id);
|
||||||
|
if (!standard) {
|
||||||
|
const t = await getTranslations("standards");
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
}
|
||||||
|
return { success: true, data: { standard } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建课标
|
||||||
|
*/
|
||||||
|
export async function createStandardAction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ standard: Standard }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_MANAGE);
|
||||||
|
const t = await getTranslations("standards");
|
||||||
|
const parseResult = await safeParseWithI18n(createStandardSchema, input);
|
||||||
|
if (!parseResult.success) return parseResult;
|
||||||
|
|
||||||
|
const auth = await getAuthContext();
|
||||||
|
if (!auth.userId) {
|
||||||
|
return { success: false, message: t("error.unauthorized") };
|
||||||
|
}
|
||||||
|
const standard = await createStandard(parseResult.data, auth.userId);
|
||||||
|
revalidatePath("/admin/standards");
|
||||||
|
return { success: true, data: { standard } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新课标
|
||||||
|
*/
|
||||||
|
export async function updateStandardAction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ standard: Standard }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_MANAGE);
|
||||||
|
const t = await getTranslations("standards");
|
||||||
|
const parseResult = await safeParseWithI18n(updateStandardSchema, input);
|
||||||
|
if (!parseResult.success) return parseResult;
|
||||||
|
|
||||||
|
const { id, ...patch } = parseResult.data;
|
||||||
|
const updated = await updateStandard(id, patch);
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: t("error.notFound") };
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/standards");
|
||||||
|
return { success: true, data: { standard: updated } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停用课标(软删除)
|
||||||
|
*/
|
||||||
|
export async function deactivateStandardAction(id: string): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_MANAGE);
|
||||||
|
await deactivateStandard(id);
|
||||||
|
revalidatePath("/admin/standards");
|
||||||
|
return { success: true, data: null };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索课标
|
||||||
|
*/
|
||||||
|
export async function searchStandardsAction(
|
||||||
|
keyword: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<ActionState<{ items: Standard[] }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_READ);
|
||||||
|
const items = await searchStandards(keyword, limit);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课案关联的课标
|
||||||
|
*/
|
||||||
|
export async function getPlanStandardsAction(
|
||||||
|
planId: string,
|
||||||
|
): Promise<ActionState<{ links: LessonPlanStandardLink[] }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_READ);
|
||||||
|
const links = await getStandardsByPlanId(planId);
|
||||||
|
return { success: true, data: { links } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联课案与课标
|
||||||
|
*/
|
||||||
|
export async function linkPlanToStandardAction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ link: LessonPlanStandardLink }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_LINK);
|
||||||
|
const t = await getTranslations("standards");
|
||||||
|
const parseResult = await safeParseWithI18n(linkPlanToStandardSchema, input);
|
||||||
|
if (!parseResult.success) return parseResult;
|
||||||
|
|
||||||
|
const auth = await getAuthContext();
|
||||||
|
if (!auth.userId) {
|
||||||
|
return { success: false, message: t("error.unauthorized") };
|
||||||
|
}
|
||||||
|
const link = await linkPlanToStandard(
|
||||||
|
parseResult.data.planId,
|
||||||
|
parseResult.data.standardId,
|
||||||
|
parseResult.data.relationType,
|
||||||
|
auth.userId,
|
||||||
|
);
|
||||||
|
revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`);
|
||||||
|
return { success: true, data: { link } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消课案与课标的关联
|
||||||
|
*/
|
||||||
|
export async function unlinkPlanFromStandardAction(
|
||||||
|
planId: string,
|
||||||
|
standardId: string,
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.STANDARD_LINK);
|
||||||
|
await unlinkPlanFromStandard(planId, standardId);
|
||||||
|
revalidatePath(`/teacher/lesson-plans/${planId}/edit`);
|
||||||
|
return { success: true, data: null };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/modules/standards/data-access.ts
Normal file
265
src/modules/standards/data-access.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* M1 课标(Standards)模块 - 数据访问层
|
||||||
|
*
|
||||||
|
* 严格遵守三层架构:本文件只被 modules/standards/actions.ts 和 app/ 路由调用,
|
||||||
|
* 不直接被其他业务模块引用。其他模块如需查询课标,应通过本文件导出的函数。
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { db } from "@/shared/db";
|
||||||
|
import { standards, lessonPlanStandards } from "@/shared/db/schema";
|
||||||
|
import { and, eq, asc, like } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
Standard,
|
||||||
|
StandardTreeNode,
|
||||||
|
LessonPlanStandardLink,
|
||||||
|
GetStandardsParams,
|
||||||
|
} from "./types";
|
||||||
|
import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课标列表(可按层级/学科/年级过滤,可选返回树形结构)
|
||||||
|
*/
|
||||||
|
export async function getStandards(
|
||||||
|
params: GetStandardsParams = {},
|
||||||
|
): Promise<Standard[]> {
|
||||||
|
const conditions = [];
|
||||||
|
if (params.level) conditions.push(eq(standards.level, params.level));
|
||||||
|
if (params.parentId) conditions.push(eq(standards.parentId, params.parentId));
|
||||||
|
if (params.subjectId) conditions.push(eq(standards.subjectId, params.subjectId));
|
||||||
|
if (params.gradeId) conditions.push(eq(standards.gradeId, params.gradeId));
|
||||||
|
if (params.stage) conditions.push(eq(standards.stage, params.stage));
|
||||||
|
if (!params.includeInactive) conditions.push(eq(standards.isActive, true));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(standards)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(asc(standards.sortOrder), asc(standards.code));
|
||||||
|
|
||||||
|
return rows.map(mapRowToStandard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课标树形结构
|
||||||
|
*/
|
||||||
|
export async function getStandardsTree(
|
||||||
|
params: Omit<GetStandardsParams, "asTree" | "parentId"> = {},
|
||||||
|
): Promise<StandardTreeNode[]> {
|
||||||
|
const allStandards = await getStandards(params);
|
||||||
|
return buildStandardsTree(allStandards);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 获取单个课标
|
||||||
|
*/
|
||||||
|
export async function getStandardById(id: string): Promise<Standard | null> {
|
||||||
|
const rows = await db.select().from(standards).where(eq(standards.id, id)).limit(1);
|
||||||
|
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 code 获取单个课标
|
||||||
|
*/
|
||||||
|
export async function getStandardByCode(code: string): Promise<Standard | null> {
|
||||||
|
const rows = await db.select().from(standards).where(eq(standards.code, code)).limit(1);
|
||||||
|
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建课标
|
||||||
|
*/
|
||||||
|
export async function createStandard(
|
||||||
|
input: {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
level: StandardLevel;
|
||||||
|
parentId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
stage?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
},
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<Standard> {
|
||||||
|
const [row] = await db.insert(standards).values({
|
||||||
|
code: input.code,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
level: input.level,
|
||||||
|
parentId: input.parentId,
|
||||||
|
subjectId: input.subjectId,
|
||||||
|
gradeId: input.gradeId,
|
||||||
|
stage: input.stage,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
isActive: true,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
const insertedId = row.insertId;
|
||||||
|
const created = await getStandardById(String(insertedId));
|
||||||
|
if (!created) throw new Error("STANDARD_CREATE_FAILED");
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新课标
|
||||||
|
*/
|
||||||
|
export async function updateStandard(
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
parentId?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<Standard | null> {
|
||||||
|
await db
|
||||||
|
.update(standards)
|
||||||
|
.set({
|
||||||
|
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||||
|
...(patch.description !== undefined ? { description: patch.description } : {}),
|
||||||
|
...(patch.parentId !== undefined ? { parentId: patch.parentId } : {}),
|
||||||
|
...(patch.sortOrder !== undefined ? { sortOrder: patch.sortOrder } : {}),
|
||||||
|
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(standards.id, id));
|
||||||
|
return getStandardById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软删除课标(标记 isActive=false,保留关联数据完整性)
|
||||||
|
*/
|
||||||
|
export async function deactivateStandard(id: string): Promise<void> {
|
||||||
|
await db.update(standards).set({ isActive: false }).where(eq(standards.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按关键词搜索课标
|
||||||
|
*/
|
||||||
|
export async function searchStandards(
|
||||||
|
keyword: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Standard[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(standards)
|
||||||
|
.where(like(standards.title, `%${keyword}%`))
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy(asc(standards.sortOrder));
|
||||||
|
return rows.map(mapRowToStandard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 课案 ↔ 课标 关联 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询课案关联的所有课标
|
||||||
|
*/
|
||||||
|
export async function getStandardsByPlanId(
|
||||||
|
planId: string,
|
||||||
|
): Promise<LessonPlanStandardLink[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
link: lessonPlanStandards,
|
||||||
|
standard: standards,
|
||||||
|
})
|
||||||
|
.from(lessonPlanStandards)
|
||||||
|
.innerJoin(standards, eq(lessonPlanStandards.standardId, standards.id))
|
||||||
|
.where(eq(lessonPlanStandards.planId, planId))
|
||||||
|
.orderBy(asc(lessonPlanStandards.createdAt));
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.link.id,
|
||||||
|
planId: r.link.planId,
|
||||||
|
standardId: r.link.standardId,
|
||||||
|
relationType: r.link.relationType as "primary" | "related",
|
||||||
|
createdBy: r.link.createdBy,
|
||||||
|
createdAt: r.link.createdAt,
|
||||||
|
standard: mapRowToStandard(r.standard),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联课案与课标
|
||||||
|
*/
|
||||||
|
export async function linkPlanToStandard(
|
||||||
|
planId: string,
|
||||||
|
standardId: string,
|
||||||
|
relationType: "primary" | "related",
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<LessonPlanStandardLink> {
|
||||||
|
const [row] = await db.insert(lessonPlanStandards).values({
|
||||||
|
planId,
|
||||||
|
standardId,
|
||||||
|
relationType,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
const insertedId = row.insertId;
|
||||||
|
const links = await getStandardsByPlanId(planId);
|
||||||
|
const created = links.find((l) => l.id === String(insertedId));
|
||||||
|
if (!created) throw new Error("STANDARD_LINK_CREATE_FAILED");
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消课案与课标的关联
|
||||||
|
*/
|
||||||
|
export async function unlinkPlanFromStandard(
|
||||||
|
planId: string,
|
||||||
|
standardId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(lessonPlanStandards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lessonPlanStandards.planId, planId),
|
||||||
|
eq(lessonPlanStandards.standardId, standardId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 辅助函数 ----
|
||||||
|
|
||||||
|
function mapRowToStandard(row: typeof standards.$inferSelect): Standard {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
level: row.level as StandardLevel,
|
||||||
|
parentId: row.parentId ?? undefined,
|
||||||
|
subjectId: row.subjectId ?? undefined,
|
||||||
|
gradeId: row.gradeId ?? undefined,
|
||||||
|
stage: row.stage ?? undefined,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdBy: row.createdBy ?? undefined,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将扁平列表构建为树形结构
|
||||||
|
*/
|
||||||
|
function buildStandardsTree(items: Standard[]): StandardTreeNode[] {
|
||||||
|
const map = new Map<string, StandardTreeNode>();
|
||||||
|
const roots: StandardTreeNode[] = [];
|
||||||
|
|
||||||
|
// 第一遍:建立 id → node 映射
|
||||||
|
for (const item of items) {
|
||||||
|
map.set(item.id, { ...item, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二遍:构建父子关系
|
||||||
|
for (const item of items) {
|
||||||
|
const node = map.get(item.id)!;
|
||||||
|
if (item.parentId && map.has(item.parentId)) {
|
||||||
|
map.get(item.parentId)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
68
src/modules/standards/schema.ts
Normal file
68
src/modules/standards/schema.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* M1 课标(Standards)模块 - Zod 验证 schema
|
||||||
|
*
|
||||||
|
* 所有错误消息使用 i18n 键,由 actions 层通过 translateFieldErrors() 翻译。
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createStandardSchema = z.object({
|
||||||
|
code: z.string().min(1, "error.codeRequired").max(100, "error.codeTooLong"),
|
||||||
|
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong"),
|
||||||
|
description: z.string().max(2000, "error.descriptionTooLong").optional(),
|
||||||
|
level: z.enum(["national", "curriculum", "custom"]),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
gradeId: z.string().optional(),
|
||||||
|
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStandardSchema = z.object({
|
||||||
|
id: z.string().min(1, "error.idRequired"),
|
||||||
|
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong").optional(),
|
||||||
|
description: z.string().max(2000, "error.descriptionTooLong").optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
sortOrder: z.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getStandardsParamsSchema = z.object({
|
||||||
|
level: z.enum(["national", "curriculum", "custom"]).optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
gradeId: z.string().optional(),
|
||||||
|
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
|
||||||
|
includeInactive: z.boolean().optional(),
|
||||||
|
asTree: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importStandardsSchema = z.object({
|
||||||
|
standards: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
code: z.string().min(1, "error.codeRequired"),
|
||||||
|
title: z.string().min(1, "error.titleRequired"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
level: z.enum(["national", "curriculum", "custom"]),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
gradeId: z.string().optional(),
|
||||||
|
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
|
||||||
|
sortOrder: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, "error.atLeastOneStandard"),
|
||||||
|
conflictStrategy: z.enum(["skip", "update", "fail"]).default("skip"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const linkPlanToStandardSchema = z.object({
|
||||||
|
planId: z.string().min(1, "error.planIdRequired"),
|
||||||
|
standardId: z.string().min(1, "error.standardIdRequired"),
|
||||||
|
relationType: z.enum(["primary", "related"]).default("primary"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateStandardInput = z.infer<typeof createStandardSchema>;
|
||||||
|
export type UpdateStandardInput = z.infer<typeof updateStandardSchema>;
|
||||||
|
export type GetStandardsParamsInput = z.infer<typeof getStandardsParamsSchema>;
|
||||||
|
export type ImportStandardsInputZod = z.infer<typeof importStandardsSchema>;
|
||||||
|
export type LinkPlanToStandardInput = z.infer<typeof linkPlanToStandardSchema>;
|
||||||
83
src/modules/standards/types.ts
Normal file
83
src/modules/standards/types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* M1 课标(Standards)模块 - 类型定义
|
||||||
|
*
|
||||||
|
* 支持国家标准 / 课标 / 自定义三层级课标库。
|
||||||
|
* 课案通过 lessonPlanStandards 关联表实现多对多关系。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
|
||||||
|
|
||||||
|
/** 课标节点 */
|
||||||
|
export interface Standard {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
level: StandardLevel;
|
||||||
|
parentId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
/** 学段:primary / junior_high / senior_high */
|
||||||
|
stage?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课标树节点(带子节点) */
|
||||||
|
export interface StandardTreeNode extends Standard {
|
||||||
|
children: StandardTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课案 ↔ 课标 关联 */
|
||||||
|
export interface LessonPlanStandardLink {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
standardId: string;
|
||||||
|
/** 关联类型:primary(主要对标)/ related(相关对标) */
|
||||||
|
relationType: "primary" | "related";
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
/** 关联的课标详情(join 查询时填充) */
|
||||||
|
standard?: Standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课案课标覆盖度统计 */
|
||||||
|
export interface LessonPlanStandardsCoverage {
|
||||||
|
planId: string;
|
||||||
|
totalStandards: number;
|
||||||
|
primaryCount: number;
|
||||||
|
relatedCount: number;
|
||||||
|
coveragePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课标查询参数 */
|
||||||
|
export interface GetStandardsParams {
|
||||||
|
level?: StandardLevel;
|
||||||
|
parentId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
stage?: string;
|
||||||
|
includeInactive?: boolean;
|
||||||
|
/** 是否返回树形结构 */
|
||||||
|
asTree?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课标导入参数 */
|
||||||
|
export interface ImportStandardsInput {
|
||||||
|
standards: Array<{
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
level: StandardLevel;
|
||||||
|
parentId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
gradeId?: string;
|
||||||
|
stage?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}>;
|
||||||
|
/** 导入策略:skip(跳过已存在)/ update(更新已存在)/ fail(重复则失败) */
|
||||||
|
conflictStrategy?: "skip" | "update" | "fail";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user