feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,242 @@
"use server"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { announcements } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import { getAnnouncements, getAnnouncementById } from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types"
export async function createAnnouncementAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const parsed = CreateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const isPublished = input.status === "published"
const publishedAt = isPublished
? input.publishedAt
? new Date(input.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
const id = createId()
await db.insert(announcements).values({
id,
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
authorId: ctx.userId,
publishedAt,
})
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
return { success: true, message: "Announcement created", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function updateAnnouncementAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
const parsed = UpdateAnnouncementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
targetGradeId: formData.get("targetGradeId") || undefined,
targetClassId: formData.get("targetClassId") || undefined,
publishedAt: formData.get("publishedAt") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const input = parsed.data
const wasPublished = existing.status === "published"
const isPublished = input.status === "published"
const publishedAt = isPublished
? existing.publishedAt
? new Date(existing.publishedAt)
: new Date()
: input.publishedAt
? new Date(input.publishedAt)
: null
await db
.update(announcements)
.set({
title: input.title,
content: input.content,
type: input.type,
status: input.status,
targetGradeId: input.targetGradeId,
targetClassId: input.targetClassId,
publishedAt,
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
void wasPublished
return { success: true, message: "Announcement updated", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db.delete(announcements).where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
return { success: true, message: "Announcement deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db
.update(announcements)
.set({
status: "published",
publishedAt: existing.publishedAt ? new Date(existing.publishedAt) : new Date(),
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
return { success: true, message: "Announcement published" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: "Announcement not found" }
await db
.update(announcements)
.set({
status: "archived",
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
revalidatePath("/admin/announcements")
revalidatePath(`/admin/announcements/${id}`)
revalidatePath("/announcements")
return { success: true, message: "Announcement archived" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
export async function getAnnouncementsAction(
params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> {
try {
await requireAuth()
const data = await getAnnouncements(params)
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -0,0 +1,65 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { PlusCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { AnnouncementForm } from "./announcement-form"
import { AnnouncementList } from "./announcement-list"
import type { Announcement, AnnouncementStatus } from "../types"
export function AdminAnnouncementsView({
announcements,
grades = [],
classes = [],
initialStatus,
}: {
announcements: Announcement[]
grades?: { id: string; name: string }[]
classes?: { id: string; name: string }[]
initialStatus?: AnnouncementStatus
}) {
const router = useRouter()
const [createOpen, setCreateOpen] = useState(false)
const handleOpenChange = (open: boolean) => {
setCreateOpen(open)
if (!open) router.refresh()
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Announcements</h2>
<p className="text-muted-foreground">
Create and manage school-wide announcements.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
New Announcement
</Button>
</div>
<AnnouncementList
announcements={announcements}
canManage
initialStatus={initialStatus}
detailHrefBuilder={(id) => `/admin/announcements/${id}`}
/>
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>New Announcement</DialogTitle>
</DialogHeader>
<AnnouncementForm mode="create" grades={grades} classes={classes} />
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import Link from "next/link"
import { useMemo } from "react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import type { Announcement } from "../types"
const STATUS_LABEL: Record<Announcement["status"], string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
}
const STATUS_VARIANT: Record<
Announcement["status"],
"default" | "secondary" | "outline"
> = {
draft: "secondary",
published: "default",
archived: "outline",
}
const TYPE_LABEL: Record<Announcement["type"], string> = {
school: "School",
grade: "Grade",
class: "Class",
}
export function AnnouncementCard({
announcement,
href,
}: {
announcement: Announcement
href?: string
}) {
const card = useMemo(
() => (
<Card className="h-full transition-colors hover:bg-accent/50">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="line-clamp-2 text-base">{announcement.title}</CardTitle>
<Badge variant={STATUS_VARIANT[announcement.status]} className="shrink-0">
{STATUS_LABEL[announcement.status]}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
{announcement.content}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="capitalize">
{TYPE_LABEL[announcement.type]}
</Badge>
<span>
{announcement.publishedAt
? `Published ${formatDate(announcement.publishedAt)}`
: `Updated ${formatDate(announcement.updatedAt)}`}
</span>
{announcement.authorName ? (
<span className="ml-auto">by {announcement.authorName}</span>
) : null}
</div>
</CardContent>
</Card>
),
[announcement]
)
if (href) {
return (
<Link href={href} className="block h-full">
{card}
</Link>
)
}
return card
}

View File

@@ -0,0 +1,206 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Archive,
ArrowLeft,
Megaphone,
Pencil,
Send,
Trash2,
} 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
import {
archiveAnnouncementAction,
deleteAnnouncementAction,
publishAnnouncementAction,
} from "../actions"
import type { Announcement } from "../types"
const STATUS_LABEL: Record<Announcement["status"], string> = {
draft: "Draft",
published: "Published",
archived: "Archived",
}
const TYPE_LABEL: Record<Announcement["type"], string> = {
school: "School",
grade: "Grade",
class: "Class",
}
export function AnnouncementDetail({
announcement,
canManage,
editHref,
backHref,
}: {
announcement: Announcement
canManage?: boolean
editHref?: string
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const handlePublish = async () => {
setIsWorking(true)
try {
const res = await publishAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to publish")
}
} catch {
toast.error("Failed to publish")
} finally {
setIsWorking(false)
}
}
const handleArchive = async () => {
setIsWorking(true)
try {
const res = await archiveAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to archive")
}
} catch {
toast.error("Failed to archive")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
setIsWorking(true)
try {
const res = await deleteAnnouncementAction(announcement.id)
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
router.refresh()
} else {
toast.error(res.message || "Failed to delete")
}
} catch {
toast.error("Failed to delete")
} finally {
setIsWorking(false)
setDeleteOpen(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{backHref ? (
<Button asChild variant="ghost" size="icon">
<a href={backHref}>
<ArrowLeft className="h-4 w-4" />
</a>
</Button>
) : null}
<h2 className="text-2xl font-bold tracking-tight">Announcement</h2>
</div>
{canManage ? (
<div className="flex flex-wrap items-center gap-2">
{announcement.status !== "published" ? (
<Button onClick={handlePublish} disabled={isWorking} variant="outline">
<Send className="mr-2 h-4 w-4" />
Publish
</Button>
) : null}
{announcement.status !== "archived" ? (
<Button onClick={handleArchive} disabled={isWorking} variant="outline">
<Archive className="mr-2 h-4 w-4" />
Archive
</Button>
) : null}
{editHref ? (
<Button asChild>
<a href={editHref}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</a>
</Button>
) : null}
<Button
onClick={() => setDeleteOpen(true)}
disabled={isWorking}
variant="destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
) : null}
</div>
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="capitalize">
{TYPE_LABEL[announcement.type]}
</Badge>
<Badge className="capitalize">{STATUS_LABEL[announcement.status]}</Badge>
</div>
<CardTitle className="text-2xl">{announcement.title}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Megaphone className="h-3 w-3" />
<span>
{announcement.publishedAt
? `Published ${formatDate(announcement.publishedAt)}`
: `Created ${formatDate(announcement.createdAt)}`}
</span>
{announcement.authorName ? <span>by {announcement.authorName}</span> : null}
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{announcement.content}</p>
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete announcement</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{announcement.title}&quot;.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,201 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
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 { createAnnouncementAction, updateAnnouncementAction } from "../actions"
import type { Announcement } from "../types"
type Mode = "create" | "edit"
export function AnnouncementForm({
mode,
announcement,
grades = [],
classes = [],
}: {
mode: Mode
announcement?: Announcement
grades?: { id: string; name: string }[]
classes?: { id: string; name: string }[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [type, setType] = useState<string>(announcement?.type ?? "school")
const [status, setStatus] = useState<string>(announcement?.status ?? "draft")
const [targetGradeId, setTargetGradeId] = useState<string>(announcement?.targetGradeId ?? "")
const [targetClassId, setTargetClassId] = useState<string>(announcement?.targetClassId ?? "")
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("type", type)
formData.set("status", status)
if (type === "grade" && targetGradeId) {
formData.set("targetGradeId", targetGradeId)
}
if (type === "class" && targetClassId) {
formData.set("targetClassId", targetClassId)
}
const res =
mode === "create"
? await createAnnouncementAction(null, formData)
: announcement
? await updateAnnouncementAction(announcement.id, null, formData)
: null
if (!res) {
toast.error("Invalid form state")
return
}
if (res.success) {
toast.success(res.message)
router.push("/admin/announcements")
router.refresh()
} else {
toast.error(res.message || "Failed to save announcement")
}
} catch {
toast.error("Failed to save announcement")
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>
{mode === "create" ? "New Announcement" : "Edit Announcement"}
</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
name="title"
placeholder="Announcement title"
defaultValue={announcement?.title ?? ""}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
name="content"
placeholder="Write the announcement content..."
className="min-h-[160px]"
defaultValue={announcement?.content ?? ""}
required
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="school">School</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
<SelectItem value="class">Class</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="type" value={type} />
</div>
<div className="grid gap-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="status" value={status} />
</div>
{type === "grade" ? (
<div className="grid gap-2 md:col-span-2">
<Label>Target Grade</Label>
<Select value={targetGradeId} onValueChange={setTargetGradeId}>
<SelectTrigger>
<SelectValue placeholder="Select a grade (optional)" />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="targetGradeId" value={targetGradeId} />
</div>
) : null}
{type === "class" ? (
<div className="grid gap-2 md:col-span-2">
<Label>Target Class</Label>
<Select value={targetClassId} onValueChange={setTargetClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class (optional)" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="targetClassId" value={targetClassId} />
</div>
) : null}
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="outline"
onClick={() => router.push("/admin/announcements")}
disabled={isWorking}
>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Megaphone } from "lucide-react"
import { AnnouncementCard } from "./announcement-card"
import type { Announcement, AnnouncementStatus } from "../types"
type Filter = "all" | AnnouncementStatus
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "published", label: "Published" },
{ value: "draft", label: "Draft" },
{ value: "archived", label: "Archived" },
]
export function AnnouncementList({
announcements,
canManage,
createHref,
detailHrefBuilder,
initialStatus,
}: {
announcements: Announcement[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
initialStatus?: Filter
}) {
const router = useRouter()
const [filter, setFilter] = useState<Filter>(initialStatus ?? "all")
const filtered = useMemo(() => {
if (filter === "all") return announcements
return announcements.filter((a) => a.status === filter)
}, [announcements, filter])
const handleFilterChange = (value: string) => {
setFilter(value as Filter)
const params = new URLSearchParams()
if (value !== "all") params.set("status", value)
const qs = params.toString()
router.replace(qs ? `?${qs}` : "?")
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Select value={filter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{canManage && createHref ? (
<Button asChild>
<a href={createHref}>
<Plus className="mr-2 h-4 w-4" />
New Announcement
</a>
</Button>
) : null}
</div>
{filtered.length === 0 ? (
<EmptyState
title="No announcements"
description={
announcements.length === 0
? "There are no announcements yet."
: "No announcements match the current filter."
}
icon={Megaphone}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
href={detailHrefBuilder ? detailHrefBuilder(a.id) : undefined}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import "server-only"
import { cache } from "react"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, users } from "@/shared/db/schema"
import type {
Announcement,
AnnouncementStatus,
AnnouncementType,
GetAnnouncementsParams,
} from "./types"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const mapRow = (
row: {
id: string
title: string
content: string
type: "school" | "grade" | "class"
status: "draft" | "published" | "archived"
targetGradeId: string | null
targetClassId: string | null
authorId: string
authorName: string | null
publishedAt: Date | null
createdAt: Date
updatedAt: Date
}
): Announcement => ({
id: row.id,
title: row.title,
content: row.content,
type: row.type,
status: row.status,
targetGradeId: row.targetGradeId,
targetClassId: row.targetClassId,
authorId: row.authorId,
authorName: row.authorName,
publishedAt: toIso(row.publishedAt),
createdAt: toIso(row.createdAt) as string,
updatedAt: toIso(row.updatedAt) as string,
})
export const getAnnouncements = cache(
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
try {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.status) {
conditions.push(eq(announcements.status, params.status as AnnouncementStatus))
}
if (params?.type) {
conditions.push(eq(announcements.type, params.type as AnnouncementType))
}
const rows = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(announcements.createdAt))
.limit(pageSize)
.offset(offset)
return rows.map(mapRow)
} catch {
return []
}
}
)
export const getAnnouncementById = cache(
async (id: string): Promise<Announcement | null> => {
try {
const [row] = await db
.select({
id: announcements.id,
title: announcements.title,
content: announcements.content,
type: announcements.type,
status: announcements.status,
targetGradeId: announcements.targetGradeId,
targetClassId: announcements.targetClassId,
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(eq(announcements.id, id))
.limit(1)
return row ? mapRow(row) : null
} catch {
return null
}
}
)

View File

@@ -0,0 +1,45 @@
import { z } from "zod"
export const CreateAnnouncementSchema = z
.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["school", "grade", "class"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
targetGradeId: z.string().trim().optional().nullable(),
targetClassId: z.string().trim().optional().nullable(),
publishedAt: z.string().optional().nullable(),
})
.transform((v) => ({
title: v.title,
content: v.content,
type: v.type ?? "school",
status: v.status ?? "draft",
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
}))
export type CreateAnnouncementInput = z.infer<typeof CreateAnnouncementSchema>
export const UpdateAnnouncementSchema = z
.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1),
type: z.enum(["school", "grade", "class"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
targetGradeId: z.string().trim().optional().nullable(),
targetClassId: z.string().trim().optional().nullable(),
publishedAt: z.string().optional().nullable(),
})
.transform((v) => ({
title: v.title,
content: v.content,
type: v.type ?? "school",
status: v.status ?? "draft",
targetGradeId: v.targetGradeId && v.targetGradeId.length > 0 ? v.targetGradeId : null,
targetClassId: v.targetClassId && v.targetClassId.length > 0 ? v.targetClassId : null,
publishedAt: v.publishedAt && v.publishedAt.length > 0 ? v.publishedAt : null,
}))
export type UpdateAnnouncementInput = z.infer<typeof UpdateAnnouncementSchema>

View File

@@ -0,0 +1,27 @@
export type AnnouncementStatus = "draft" | "published" | "archived"
export type AnnouncementType = "school" | "grade" | "class"
export interface Announcement {
id: string
title: string
content: string
type: AnnouncementType
status: AnnouncementStatus
targetGradeId: string | null
targetClassId: string | null
authorId: string
authorName: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
export type AnnouncementListItem = Announcement
export type GetAnnouncementsParams = {
status?: AnnouncementStatus
type?: AnnouncementType
page?: number
pageSize?: number
}