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