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