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,159 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { formatDate } from "@/shared/lib/utils"
import {
getNotificationsAction,
markAllNotificationsAsReadAction,
markNotificationAsReadAction,
} from "../actions"
import type { Notification, NotificationType } from "../types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
}
export function NotificationDropdown() {
const router = useRouter()
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
useEffect(() => {
let active = true
void (async () => {
const res = await getNotificationsAction({ pageSize: 10 })
if (!active) return
if (res.success && res.data) {
setNotifications(res.data.items)
setUnreadCount(res.data.items.filter((n) => !n.isRead).length)
}
})()
return () => {
active = false
}
}, [])
const handleMarkAllRead = async () => {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
setUnreadCount(0)
router.refresh()
}
}
const handleMarkRead = async (id: string) => {
const res = await markNotificationAsReadAction(id)
if (res.success) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
)
setUnreadCount((c) => Math.max(0, c - 1))
router.refresh()
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground">
<Bell className="size-5" />
{unreadCount > 0 ? (
<Badge
variant="destructive"
className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center px-1 text-[10px]"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
) : null}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Notifications</span>
{unreadCount > 0 ? (
<button
type="button"
onClick={handleMarkAllRead}
className="text-primary text-xs hover:underline"
>
<CheckCheck className="mr-1 inline h-3 w-3" />
Mark all read
</button>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator className="mb-0" />
<ScrollArea className="max-h-[320px]">
{notifications.length === 0 ? (
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
No notifications
</div>
) : (
notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell
return (
<DropdownMenuItem
key={n.id}
className="flex items-start gap-2 py-3"
onSelect={(e) => {
if (!n.isRead) {
e.preventDefault()
handleMarkRead(n.id)
}
}}
>
<div className="bg-muted mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5">
{!n.isRead ? (
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
) : null}
<span className={`text-xs ${!n.isRead ? "font-semibold" : "font-medium"}`}>
{n.title}
</span>
</div>
{n.content ? (
<p className="text-muted-foreground line-clamp-2 text-xs">{n.content}</p>
) : null}
<span className="text-muted-foreground text-[10px]">
{formatDate(n.createdAt)}
</span>
</div>
</DropdownMenuItem>
)
})
)}
</ScrollArea>
<DropdownMenuSeparator className="mt-0" />
<DropdownMenuItem asChild>
<Link href="/messages" className="text-primary justify-center text-xs">
View all notifications
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}