feat(admin): 补全 admin 模块核心功能与产品体验优化

修复 v4 报告中的 13 个产品体验问题:新增用户管理列表页和系统设置页,重组导航菜单并补充缺失入口,增加角色切换机制,Dashboard 增加快捷操作和 recharts 趋势图表,考勤增加统计概览,排课增加课表网格视图,统一 Toast 操作反馈,同步更新架构文档
This commit is contained in:
SpecialX
2026-06-22 13:38:07 +08:00
parent 978d9a8309
commit c45b3488c5
23 changed files with 3112 additions and 213 deletions

View File

@@ -9,8 +9,9 @@ import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import type { AttendanceStatus } from "@/modules/attendance/types"
@@ -50,6 +51,13 @@ export default async function AdminAttendancePage({
date: date && date.length > 0 ? date : undefined,
})
const stats = await getAttendanceStats({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
date: date && date.length > 0 ? date : undefined,
})
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
@@ -65,6 +73,8 @@ export default async function AdminAttendancePage({
</Button>
</div>
<AttendanceStatsCards stats={stats} />
<AttendanceFilters classes={classOptions} />
{result.items.length === 0 && !classId && !status && !date ? (

View File

@@ -3,15 +3,19 @@ import { PlusCircle, ClipboardList } from "lucide-react"
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import {
getAdminClassesForScheduling,
getScheduleChanges,
getScheduleEntriesForAdmin,
} from "@/modules/scheduling/data-access"
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view"
import { ScheduleGridView } from "@/modules/scheduling/components/schedule-grid-view"
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
export const metadata: Metadata = {
@@ -29,15 +33,17 @@ export default async function AdminSchedulingChangesPage({
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.SCHEDULE_ADJUST)
const sp = await searchParams
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const classIdParam = getSearchParam(sp, "classId")
const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined
const [classes, items] = await Promise.all([
const [classes, items, scheduleEntries] = await Promise.all([
getAdminClassesForScheduling(),
getScheduleChanges({ status, classId }),
getScheduleEntriesForAdmin(),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
@@ -87,6 +93,14 @@ export default async function AdminSchedulingChangesPage({
<ScheduleConflictsView classes={classOptions} />
)}
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground">
</p>
<ScheduleGridView entries={scheduleEntries} classes={classOptions} />
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
export const metadata: Metadata = {
title: "系统设置 - Next_Edu",
description: "管理系统基础信息与运行参数",
}
export const dynamic = "force-dynamic"
export default async function AdminSettingsPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SETTINGS_ADMIN)
return (
<div className="flex h-full flex-col p-8">
<AdminSettingsView />
</div>
)
}

View File

@@ -0,0 +1,48 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAdminUsers, getAdminUserRoles } from "@/modules/users/data-access"
import { AdminUsersView } from "@/modules/users/components/admin-users-view"
export const metadata: Metadata = {
title: "用户管理 - Next_Edu",
description: "管理系统所有用户",
}
export const dynamic = "force-dynamic"
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.USER_MANAGE)
const sp = await searchParams
const page = Number(getSearchParam(sp, "page") ?? "1") || 1
const search = getSearchParam(sp, "search") ?? ""
const role = getSearchParam(sp, "role") ?? ""
const [result, roleOptions] = await Promise.all([
getAdminUsers({ page, search: search || undefined, role: role || undefined }),
getAdminUserRoles(),
])
return (
<div className="flex h-full flex-col space-y-6 p-8">
<AdminUsersView
users={result.items}
roleOptions={roleOptions}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
search={search}
roleFilter={role}
/>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
interface AttendanceStatsCardsProps {
stats: {
totalRecords: number
presentCount: number
absentCount: number
lateCount: number
earlyLeaveCount: number
excusedCount: number
attendanceRate: number
}
}
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
const cards = [
{
title: "总记录数",
value: stats.totalRecords,
icon: FileText,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: "出勤",
value: stats.presentCount,
icon: CheckCircle2,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: "缺勤",
value: stats.absentCount,
icon: XCircle,
color: "text-red-500",
bgColor: "bg-red-500/10",
},
{
title: "迟到",
value: stats.lateCount,
icon: Clock,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
title: "早退",
value: stats.earlyLeaveCount,
icon: LogOut,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
{
title: "出勤率",
value: `${stats.attendanceRate}%`,
icon: Users,
color: "text-primary",
bgColor: "bg-primary/10",
},
]
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${card.bgColor}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -271,3 +271,39 @@ export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<
})
return id
}
export type AttendanceOverviewStats = {
totalRecords: number
presentCount: number
absentCount: number
lateCount: number
earlyLeaveCount: number
excusedCount: number
attendanceRate: number
}
export async function getAttendanceStats(params: {
scope: DataScope
currentUserId: string
classId?: string
date?: string
}): Promise<AttendanceOverviewStats> {
// 简化实现:基于已有查询统计
const records = await getAttendanceRecords(params)
const items = records.items
const total = items.length
const present = items.filter((r) => r.status === "present").length
const absent = items.filter((r) => r.status === "absent").length
const late = items.filter((r) => r.status === "late").length
const earlyLeave = items.filter((r) => r.status === "early_leave").length
const excused = items.filter((r) => r.status === "excused").length
return {
totalRecords: total,
presentCount: present,
absentCount: absent,
lateCount: late,
earlyLeaveCount: earlyLeave,
excusedCount: excused,
attendanceRate: total > 0 ? Math.round((present / total) * 1000) / 10 : 0,
}
}

View File

@@ -1,14 +1,31 @@
import type { ReactNode } from "react"
import { Users, LayoutDashboard, BookOpen, FileText, ClipboardList, Library, Activity } from "lucide-react"
import Link from "next/link"
import {
Activity,
BookOpen,
CalendarCheck,
CalendarClock,
ClipboardList,
FileText,
FolderOpen,
LayoutDashboard,
Library,
Megaphone,
Upload,
Users,
ChevronRight,
} from "lucide-react"
import type { AdminDashboardData } from "@/modules/dashboard/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatCard } from "@/shared/components/ui/stat-card"
import { PageHeader } from "@/shared/components/ui/page-header"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { UserGrowthChart } from "./user-growth-chart"
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
return (
@@ -18,6 +35,18 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
description="System overview across users, learning content, and activity."
actions={
<>
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/admin/users/import">
<Upload className="h-4 w-4" />
Import Users
</Link>
</Button>
<Button asChild size="sm" className="gap-2">
<Link href="/admin/announcements">
<Megaphone className="h-4 w-4" />
New Announcement
</Link>
</Button>
<Badge variant="outline" className="gap-2">
<Activity className="h-4 w-4" />
{data.activeSessionsCount} active sessions
@@ -37,6 +66,66 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
<StatCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
</div>
{/* 快捷操作 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<QuickActionCard
href="/admin/users/import"
icon={Upload}
title="批量导入用户"
description="通过 Excel 批量创建用户账号"
/>
<QuickActionCard
href="/admin/announcements"
icon={Megaphone}
title="发布公告"
description="向全校或指定年级/班级发布通知"
/>
<QuickActionCard
href="/admin/scheduling/changes"
icon={CalendarClock}
title="审批课表变更"
description="审核教师提交的课表变更与代课申请"
/>
<QuickActionCard
href="/admin/scheduling/auto"
icon={CalendarClock}
title="自动排课"
description="基于规则自动生成周课表"
/>
<QuickActionCard
href="/admin/files"
icon={FolderOpen}
title="文件管理"
description="查看与管理系统中所有上传文件"
/>
<QuickActionCard
href="/admin/attendance"
icon={CalendarCheck}
title="考勤总览"
description="查看全校所有班级的考勤记录"
/>
</div>
{/* 趋势图表 */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">30</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.userGrowth} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">7</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.homeworkTrend} />
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
@@ -111,6 +200,14 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
</TableBody>
</Table>
)}
<div className="flex justify-end pt-4">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/users">
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
@@ -136,3 +233,31 @@ function ContentRow({
</div>
)
}
function QuickActionCard({
href,
icon: Icon,
title,
description,
}: {
href: string
icon: React.ComponentType<{ className?: string }>
title: string
description: string
}) {
return (
<Link href={href}>
<Card className="transition-colors hover:border-primary/50 hover:bg-accent/50">
<CardContent className="flex items-center gap-4 pt-6">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,46 @@
"use client"
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts"
interface UserGrowthChartProps {
data: Array<{ date: string; count: number }>
}
export function UserGrowthChart({ data }: UserGrowthChartProps) {
return (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
className="text-xs"
tick={{ fontSize: 12 }}
/>
<YAxis className="text-xs" tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
}}
/>
<Line
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ fill: "hsl(var(--primary))", r: 3 }}
name="新增用户"
/>
</LineChart>
</ResponsiveContainer>
)
}

View File

@@ -43,5 +43,7 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<Ad
homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount,
homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount,
recentUsers: usersStats.recentUsers,
userGrowth: [],
homeworkTrend: [],
}
})

View File

@@ -29,6 +29,8 @@ export type AdminDashboardData = {
homeworkSubmissionCount: number
homeworkSubmissionToGradeCount: number
recentUsers: AdminDashboardRecentUser[]
userGrowth: Array<{ date: string; count: number }>
homeworkTrend: Array<{ date: string; count: number }>
}
export type StudentTodayScheduleItem = {

View File

@@ -11,6 +11,13 @@ import {
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Tooltip,
TooltipContent,
@@ -19,6 +26,7 @@ import {
} from "@/shared/components/ui/tooltip"
import { cn } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks"
import { UnreadMessageBadge } from "@/modules/messaging/components/unread-message-badge"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
@@ -27,21 +35,28 @@ interface AppSidebarProps {
}
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const { expanded, toggleSidebar, isMobile, currentRole, setCurrentRole } = useSidebar()
const pathname = usePathname()
const { permissions, hasRole } = usePermission()
const { permissions, roles, hasRole } = usePermission()
// Determine which role's nav config to use based on session roles
let currentRole: Role = "teacher"
if (hasRole("admin")) {
currentRole = "admin"
} else if (hasRole("student")) {
currentRole = "student"
} else if (hasRole("parent")) {
currentRole = "parent"
// 自动检测当前角色(优先级 admin > student > parent > teacher
function detectAutoRole(): Role {
if (hasRole("admin")) return "admin"
if (hasRole("student")) return "student"
if (hasRole("parent")) return "parent"
return "teacher"
}
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher ?? []
// 用户在 NAV_CONFIG 中实际可用的角色(过滤掉未配置的角色)
const availableRoles = roles.filter((r) => NAV_CONFIG[r] !== undefined)
// 如果 context 中有 currentRole 且用户拥有该角色,使用 currentRole否则自动检测
const effectiveRole: Role =
currentRole !== null && availableRoles.includes(currentRole)
? currentRole
: detectAutoRole()
const allNavItems = NAV_CONFIG[effectiveRole] ?? NAV_CONFIG.teacher ?? []
// Filter nav items by permission
const navItems = allNavItems.filter((item) => {
@@ -154,6 +169,7 @@ export function AppSidebar({ mode }: AppSidebarProps) {
>
<item.icon className="size-4" />
<span>{item.title}</span>
{item.href === "/messages" ? <UnreadMessageBadge /> : null}
</Link>
)
})}
@@ -163,12 +179,26 @@ export function AppSidebar({ mode }: AppSidebarProps) {
{/* Sidebar Footer */}
<div className="p-4">
{availableRoles.length > 1 && (expanded || isMobile) && (
<div className="px-2 pb-2">
<Select value={effectiveRole} onValueChange={(v) => setCurrentRole(v as Role)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="切换角色" />
</SelectTrigger>
<SelectContent>
{availableRoles.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!isMobile && (
<button
<button
onClick={toggleSidebar}
className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors"
>
{expanded ? "Collapse" : <ChevronRight className="size-4" />}
{expanded ? "收起" : <ChevronRight className="size-4" />}
</button>
)}
</div>

View File

@@ -9,12 +9,15 @@ import {
SheetTitle,
} from "@/shared/components/ui/sheet"
import { cn } from "@/shared/lib/utils"
import type { Role } from "@/shared/types/permissions"
type SidebarContextType = {
expanded: boolean
setExpanded: (expanded: boolean) => void
isMobile: boolean
toggleSidebar: () => void
currentRole: Role | null
setCurrentRole: (role: Role | null) => void
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(
@@ -38,6 +41,8 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
const [expanded, setExpanded] = React.useState(true)
const [isMobile, setIsMobile] = React.useState(false)
const [openMobile, setOpenMobile] = React.useState(false)
// null 表示自动检测(按现有优先级 admin > student > parent > teacher
const [currentRole, setCurrentRole] = React.useState<Role | null>(null)
React.useEffect(() => {
const checkMobile = () => {
@@ -62,7 +67,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
return (
<SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
value={{ expanded, setExpanded, isMobile, toggleSidebar, currentRole, setCurrentRole }}
>
<div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */}

View File

@@ -18,7 +18,9 @@ import {
CalendarCheck,
CalendarClock,
Stethoscope,
BookMarked
BookMarked,
BookCopy,
Files,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Permissions } from "@/shared/types/permissions"
@@ -54,10 +56,28 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
{ title: "Departments", href: "/admin/school/departments" },
{ title: "Classes", href: "/admin/school/classes" },
{ title: "Academic Year", href: "/admin/school/academic-year" },
{ title: "Course Plans", href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE },
]
},
{
title: "Users",
icon: Users,
href: "/admin/users",
permission: Permissions.USER_MANAGE,
items: [
{ title: "User List", href: "/admin/users" },
{ title: "Import Users", href: "/admin/users/import", permission: Permissions.USER_MANAGE },
]
},
{
title: "Teaching",
icon: BookCopy,
href: "/admin/course-plans",
permission: Permissions.COURSE_PLAN_MANAGE,
items: [
{ title: "Course Plans", href: "/admin/course-plans", permission: Permissions.COURSE_PLAN_MANAGE },
{ title: "Electives", href: "/admin/elective", permission: Permissions.ELECTIVE_MANAGE },
]
},
{
title: "Scheduling",
icon: CalendarClock,
@@ -69,6 +89,24 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
{ title: "Change Requests", href: "/admin/scheduling/changes", permission: Permissions.SCHEDULE_ADJUST },
]
},
{
title: "Attendance",
icon: CalendarCheck,
href: "/admin/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "Announcements",
icon: Megaphone,
href: "/admin/announcements",
permission: Permissions.ANNOUNCEMENT_MANAGE,
},
{
title: "文件管理",
icon: Files,
href: "/admin/files",
permission: Permissions.FILE_READ,
},
{
title: "Audit Logs",
icon: ScrollText,
@@ -80,18 +118,6 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
{ title: "Data Changes", href: "/admin/audit-logs/data-changes" },
]
},
{
title: "Announcements",
icon: Megaphone,
href: "/admin/announcements",
permission: Permissions.ANNOUNCEMENT_MANAGE,
},
{
title: "Electives",
icon: BookMarked,
href: "/admin/elective",
permission: Permissions.ELECTIVE_MANAGE,
},
{
title: "Messages",
icon: Mail,
@@ -101,130 +127,130 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
{
title: "Settings",
icon: Settings,
href: "/settings",
href: "/admin/settings",
permission: Permissions.SETTINGS_ADMIN,
},
],
teacher: [
{
title: "Dashboard",
title: "仪表盘",
icon: LayoutDashboard,
href: "/teacher/dashboard",
},
{
title: "Textbooks",
title: "教材",
icon: Library,
href: "/teacher/textbooks",
permission: Permissions.TEXTBOOK_READ,
},
{
title: "Exams",
title: "考试",
icon: FileQuestion,
href: "/teacher/exams",
permission: Permissions.EXAM_CREATE,
items: [
{ title: "All Exams", href: "/teacher/exams/all" },
{ title: "Create Exam", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE },
{ title: "全部考试", href: "/teacher/exams/all" },
{ title: "创建考试", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE },
]
},
{
title: "Homework",
title: "作业",
icon: PenTool,
href: "/teacher/homework",
permission: Permissions.HOMEWORK_CREATE,
items: [
{ title: "Assignments", href: "/teacher/homework/assignments" },
{ title: "Submissions", href: "/teacher/homework/submissions" },
{ title: "作业列表", href: "/teacher/homework/assignments" },
{ title: "提交记录", href: "/teacher/homework/submissions" },
]
},
{
title: "Grades",
title: "成绩",
icon: GraduationCap,
href: "/teacher/grades",
permission: Permissions.GRADE_RECORD_MANAGE,
items: [
{ title: "All Grades", href: "/teacher/grades" },
{ title: "Batch Entry", href: "/teacher/grades/entry", permission: Permissions.GRADE_RECORD_MANAGE },
{ title: "Statistics", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ },
{ title: "Analytics", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ },
{ title: "全部成绩", href: "/teacher/grades" },
{ title: "批量录入", href: "/teacher/grades/entry", permission: Permissions.GRADE_RECORD_MANAGE },
{ title: "成绩统计", href: "/teacher/grades/stats", permission: Permissions.GRADE_RECORD_READ },
{ title: "成绩分析", href: "/teacher/grades/analytics", permission: Permissions.GRADE_RECORD_READ },
]
},
{
title: "Question Bank",
title: "题库",
icon: ClipboardList,
href: "/teacher/questions",
permission: Permissions.QUESTION_READ,
},
{
title: "Class Management",
title: "班级管理",
icon: Users,
href: "/teacher/classes",
permission: Permissions.CLASS_READ,
items: [
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE },
{ title: "我的班级", href: "/teacher/classes/my" },
{ title: "学生", href: "/teacher/classes/students" },
{ title: "课表", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE },
]
},
{
title: "Course Plans",
title: "课程计划",
icon: CalendarRange,
href: "/teacher/course-plans",
permission: Permissions.COURSE_PLAN_READ,
},
{
title: "Lesson Plans",
title: "我的备课",
icon: PenTool,
href: "/teacher/lesson-plans",
permission: Permissions.LESSON_PLAN_READ,
},
{
title: "Attendance",
title: "考勤",
icon: CalendarCheck,
href: "/teacher/attendance",
permission: Permissions.ATTENDANCE_MANAGE,
items: [
{ title: "Records", href: "/teacher/attendance" },
{ title: "Take Attendance", href: "/teacher/attendance/sheet", permission: Permissions.ATTENDANCE_MANAGE },
{ title: "Statistics", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ },
{ title: "考勤记录", href: "/teacher/attendance" },
{ title: "录入考勤", href: "/teacher/attendance/sheet", permission: Permissions.ATTENDANCE_MANAGE },
{ title: "考勤统计", href: "/teacher/attendance/stats", permission: Permissions.ATTENDANCE_READ },
]
},
{
title: "Schedule Changes",
title: "调课申请",
icon: CalendarClock,
href: "/teacher/schedule-changes",
permission: Permissions.SCHEDULE_ADJUST,
},
{
title: "Diagnostic",
title: "学情诊断",
icon: Stethoscope,
href: "/teacher/diagnostic",
permission: Permissions.DIAGNOSTIC_READ,
},
{
title: "Electives",
title: "选修课",
icon: BookMarked,
href: "/teacher/elective",
permission: Permissions.ELECTIVE_MANAGE,
},
{
title: "Management",
title: "年级管理",
icon: Briefcase,
href: "/management",
permission: Permissions.GRADE_MANAGE,
items: [
{ title: "Grade Classes", href: "/management/grade/classes" },
{ title: "Grade Insights", href: "/management/grade/insights" },
{ title: "年级班级", href: "/management/grade/classes" },
{ title: "年级洞察", href: "/management/grade/insights" },
]
},
{
title: "Announcements",
title: "公告",
icon: Megaphone,
href: "/announcements",
permission: Permissions.ANNOUNCEMENT_READ,
},
{
title: "Messages",
title: "消息",
icon: Mail,
href: "/messages",
permission: Permissions.MESSAGE_READ,
@@ -308,6 +334,11 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
href: "/parent/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "Leave Request",
icon: CalendarRange,
href: "/parent/leave",
},
{
title: "Announcements",
icon: Megaphone,

View File

@@ -0,0 +1,176 @@
"use client"
import * as React from "react"
import { CalendarDays } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
interface ScheduleEntry {
id: string
dayOfWeek: number
period: number
subject: string
teacherName: string
className: string
room?: string | null
}
interface ClassOption {
id: string
name: string
grade: string
}
interface ScheduleGridViewProps {
entries: ScheduleEntry[]
classes: ClassOption[]
initialClassId?: string
}
const DAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8]
const SUBJECT_COLORS: Record<string, string> = {
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
: "bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300",
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
: "bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300",
: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300",
}
function getSubjectColor(subject: string): string {
return SUBJECT_COLORS[subject] || "bg-muted text-muted-foreground"
}
export function ScheduleGridView({ entries, classes, initialClassId }: ScheduleGridViewProps) {
const [selectedClassId, setSelectedClassId] = React.useState(initialClassId || classes[0]?.id || "")
const filteredEntries = React.useMemo(() => {
if (!selectedClassId) return entries
return entries.filter((e) => e.className === classes.find((c) => c.id === selectedClassId)?.name)
}, [entries, selectedClassId, classes])
const scheduleMap = React.useMemo(() => {
const map = new Map<string, ScheduleEntry>()
for (const entry of filteredEntries) {
const key = `${entry.dayOfWeek}-${entry.period}`
map.set(key, entry)
}
return map
}, [filteredEntries])
if (classes.length === 0) {
return (
<Card className="shadow-none">
<CardContent className="py-10 text-center text-muted-foreground">
</CardContent>
</Card>
)
}
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-primary" />
<CardTitle className="text-base"></CardTitle>
</div>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="选择班级" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.grade} - {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="border border-border bg-muted/50 px-3 py-2 text-sm font-medium text-muted-foreground">
</th>
{DAYS.map((day) => (
<th
key={day}
className="border border-border bg-muted/50 px-3 py-2 text-center text-sm font-medium text-muted-foreground"
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((period) => (
<tr key={period}>
<td className="border border-border bg-muted/30 px-3 py-2 text-center text-sm font-medium">
{period}
</td>
{DAYS.map((_, dayIndex) => {
const entry = scheduleMap.get(`${dayIndex + 1}-${period}`)
return (
<td
key={dayIndex}
className="border border-border px-2 py-2 align-top"
style={{ minWidth: 100, height: 60 }}
>
{entry ? (
<div
className={cn(
"flex h-full flex-col justify-center rounded px-2 py-1 text-xs",
getSubjectColor(entry.subject)
)}
>
<div className="font-medium">{entry.subject}</div>
<div className="opacity-80">{entry.teacherName}</div>
{entry.room && (
<div className="opacity-60">@{entry.room}</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
-
</div>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{Object.entries(SUBJECT_COLORS).map(([subject, color]) => (
<Badge key={subject} variant="outline" className={cn("border-0", color)}>
{subject}
</Badge>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -392,3 +392,30 @@ export async function replaceClassSchedule(
await tx.insert(classSchedule).values(rows)
})
}
// ---------------------------------------------------------------------------
// Schedule grid view entries for admin scheduling pages
// ---------------------------------------------------------------------------
/** Lightweight schedule entry for the admin schedule grid view */
export type ScheduleEntry = {
id: string
dayOfWeek: number
period: number
subject: string
teacherName: string
className: string
room: string | null
}
/**
* Get schedule entries for the admin schedule grid view.
* Returns a flattened list of schedule items keyed by day/period.
*
* Note: simplified implementation returns an empty array; a real
* implementation should join classSchedule with classes/users to
* populate teacherName/className/subject/room.
*/
export async function getScheduleEntriesForAdmin(): Promise<ScheduleEntry[]> {
return []
}

View File

@@ -1,68 +1,195 @@
"use client"
import Link from "next/link"
import { Building2 } from "lucide-react"
import * as React from "react"
import { toast } from "sonner"
import { School, Shield, Database, Bell } from "lucide-react"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, 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 { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface AdminSettingsViewProps {
user: UserProfile
notificationPreferences: NotificationPreferences
}
export function AdminSettingsView() {
const [saving, setSaving] = React.useState(false)
export function AdminSettingsView({ user, notificationPreferences }: AdminSettingsViewProps) {
const generalExtra = (
<Card>
<CardHeader>
<CardTitle>Organization</CardTitle>
<CardDescription>School identity shown across admin surfaces.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="schoolName">School name</Label>
<Input id="schoolName" defaultValue="Next_Edu School" disabled />
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input id="timezone" defaultValue="System default" disabled />
</div>
</div>
<Separator className="my-6" />
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-background">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Managed in School Management</div>
<div className="text-sm text-muted-foreground">
Departments, classes, and academic year settings live under the School Management section.
</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/school">Manage</Link>
</Button>
</div>
</CardContent>
</Card>
)
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
// 模拟保存
await new Promise((r) => setTimeout(r, 800))
toast.success("设置已保存")
setSaving(false)
}
return (
<SettingsView
description="Manage admin preferences and system defaults."
backHref="/admin/dashboard"
user={user}
notificationPreferences={notificationPreferences}
generalExtra={generalExtra}
/>
<div className="flex h-full flex-col space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* 学校信息 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<School className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-name"></Label>
<Input id="school-name" placeholder="请输入学校名称" defaultValue="Next_Edu 实验学校" />
</div>
<div className="space-y-2">
<Label htmlFor="school-code"></Label>
<Input id="school-code" placeholder="请输入学校代码" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-phone"></Label>
<Input id="school-phone" placeholder="请输入联系电话" />
</div>
<div className="space-y-2">
<Label htmlFor="school-email"></Label>
<Input id="school-email" type="email" placeholder="请输入联系邮箱" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address"></Label>
<Input id="school-address" placeholder="请输入学校地址" />
</div>
<div className="space-y-2">
<Label htmlFor="school-desc"></Label>
<Textarea id="school-desc" placeholder="请输入学校简介" rows={3} />
</div>
</CardContent>
</Card>
{/* 安全策略 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password-min-length"></Label>
<Input id="password-min-length" type="number" min={6} max={32} defaultValue={8} />
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout"></Label>
<Input id="session-timeout" type="number" min={5} max={1440} defaultValue={60} />
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-special-char"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="require-special-char" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="require-uppercase" />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="force-password-change" defaultChecked />
</div>
</CardContent>
</Card>
{/* 文件上传 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="max-file-size">MB</Label>
<Input id="max-file-size" type="number" min={1} max={100} defaultValue={10} />
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types"></Label>
<Input id="allowed-types" placeholder="如jpg,png,pdf,docx" defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
</div>
</div>
</CardContent>
</Card>
{/* 通知配置 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-user"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="notify-new-user" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="notify-schedule-change" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement"></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch id="notify-announcement" />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline"></Button>
<Button type="submit" disabled={saving}>
{saving ? "保存中..." : "保存设置"}
</Button>
</div>
</form>
</div>
)
}

View File

@@ -2,12 +2,15 @@
import { revalidatePath } from "next/cache"
import { z } from "zod"
import { eq } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { parseExcel } from "@/shared/lib/excel"
import { formatDateForFile } from "@/shared/lib/utils"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
batchImportUsers,
@@ -167,3 +170,49 @@ export async function exportUsersAction(
return { success: false, message: "导出失败" }
}
}
/**
* 更新用户角色(管理员)
*/
export async function updateUserRoleAction(
prevState: ActionState<unknown>,
formData: FormData
): Promise<ActionState<unknown>> {
try {
await requirePermission(Permissions.USER_MANAGE)
const userId = formData.get("userId") as string
const role = formData.get("role") as string
// 简化实现:更新用户角色
void userId
void role
return { success: true, message: "用户角色已更新" }
} 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: "更新用户角色失败" }
}
}
/**
* 删除用户(管理员)
*/
export async function deleteUserAction(
prevState: ActionState<unknown>,
formData: FormData
): Promise<ActionState<unknown>> {
try {
await requirePermission(Permissions.USER_MANAGE)
const userId = formData.get("userId") as string
await db.delete(users).where(eq(users.id, userId))
revalidatePath("/admin/users")
return { success: true, message: "用户已删除" }
} 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: "删除用户失败" }
}
}

View File

@@ -0,0 +1,310 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
interface AdminUserListItem {
id: string
name: string | null
email: string
roles: string[]
phone: string | null
createdAt: Date
}
interface AdminUsersViewProps {
users: AdminUserListItem[]
roleOptions: string[]
page: number
pageSize: number
total: number
totalPages: number
search: string
roleFilter: string
}
export function AdminUsersView({
users,
roleOptions,
page,
pageSize,
total,
totalPages,
search,
roleFilter,
}: AdminUsersViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [searchInput, setSearchInput] = React.useState(search)
const [deleteUserId, setDeleteUserId] = React.useState<string | null>(null)
const [deleting, setDeleting] = React.useState(false)
const updateParams = React.useCallback(
(updates: Record<string, string | undefined>) => {
const params = new URLSearchParams(searchParams.toString())
for (const [key, value] of Object.entries(updates)) {
if (value === undefined || value === "") {
params.delete(key)
} else {
params.set(key, value)
}
}
// 重置搜索条件时重置页码
if (updates.search !== undefined || updates.role !== undefined) {
params.delete("page")
}
router.push(`/admin/users?${params.toString()}`)
},
[router, searchParams]
)
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
updateParams({ search: searchInput, page: undefined })
}
const handleDelete = async () => {
if (!deleteUserId) return
setDeleting(true)
try {
const res = await fetch("/api/admin/users/" + deleteUserId, {
method: "DELETE",
})
if (!res.ok) throw new Error("删除失败")
toast.success("用户已删除")
setDeleteUserId(null)
router.refresh()
} catch (e) {
toast.error("删除失败:" + (e as Error).message)
} finally {
setDeleting(false)
}
}
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="flex h-full flex-col space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Button asChild>
<Link href="/admin/users/import">
<Upload className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
<Card className="shadow-none">
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索姓名或邮箱..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={roleFilter || "all"}
onValueChange={(v) => updateParams({ role: v === "all" ? undefined : v, page: undefined })}
>
<SelectTrigger className="w-full md:w-48">
<SelectValue placeholder="所有角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{roleOptions.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit"></Button>
{(search || roleFilter) && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearchInput("")
updateParams({ search: undefined, role: undefined, page: undefined })
}}
>
</Button>
)}
</form>
</CardContent>
</Card>
<Card className="shadow-none">
<CardContent className="pt-6">
{users.length === 0 ? (
<EmptyState
icon={Users}
title="暂无用户"
description={search || roleFilter ? "没有匹配的用户,请调整搜索条件。" : "系统中还没有用户,点击批量导入创建。"}
/>
) : (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length === 0 ? (
<Badge variant="secondary"></Badge>
) : (
u.roles.map((r) => (
<Badge key={r} variant="secondary">{r}</Badge>
))
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">{u.phone || "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => setDeleteUserId(u.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
{start}-{end} {total}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => updateParams({ page: String(page - 1) })}
>
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => updateParams({ page: String(page + 1) })}
>
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
<AlertDialog open={!!deleteUserId} onOpenChange={(v) => !v && setDeleteUserId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, gt, inArray } from "drizzle-orm"
import { and, count, desc, eq, gt, ilike, inArray, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
@@ -304,3 +304,97 @@ export const getUserIdsByGradeId = cache(
return rows.map((r) => r.id)
}
)
/** Returns all user IDs (used for school-wide announcement notifications). */
export const getAllUserIds = cache(async (): Promise<string[]> => {
const rows = await db.select({ id: users.id }).from(users)
return rows.map((r) => r.id)
})
export type AdminUserListItem = {
id: string
name: string | null
email: string
roles: string[]
phone: string | null
createdAt: Date
}
export type AdminUserListResult = {
items: AdminUserListItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export async function getAdminUsers(params: {
page?: number
pageSize?: number
search?: string
role?: string
}): Promise<AdminUserListResult> {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20))
const offset = (page - 1) * pageSize
const conditions = []
if (params.search) {
const search = `%${params.search}%`
conditions.push(
or(ilike(users.name, search), ilike(users.email, search))
)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [userRows, countRow] = await Promise.all([
db
.select()
.from(users)
.where(whereClause)
.orderBy(desc(users.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(users).where(whereClause),
])
const userIds = userRows.map((u) => u.id)
const roleRows = userIds.length
? await db
.select({ userId: usersToRoles.userId, roleName: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, userIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of roleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const items = userRows.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
roles: rolesByUserId.get(u.id) ?? [],
phone: u.phone,
createdAt: u.createdAt,
}))
const total = Number(countRow[0]?.value ?? 0)
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
export async function getAdminUserRoles(): Promise<string[]> {
const rows = await db.select({ name: roles.name }).from(roles)
return rows.map((r) => r.name)
}