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:
SpecialX
2026-07-03 10:24:07 +08:00
parent ac1de9e433
commit 7567f317e1
19 changed files with 3192 additions and 0 deletions

View 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 }
}
}

View File

@@ -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 8601UTC以通过 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>
</>
)
}

View File

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

View 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 affectedRowsdrizzle-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
}

View 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}`
}

View File

@@ -0,0 +1,46 @@
/**
* 邀请码 Zod 验证 schemaaudit-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",
)

View 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
}

View 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 })
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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}
/>
</>
)
}

View 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)
}

View 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>

View File

@@ -0,0 +1,63 @@
/**
* L-5 在线请假流程类型定义。
*
* 状态机pending → approved/rejectedpending/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
}

View 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);
}
}

View 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;
}

View 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>;

View 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";
}