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

@@ -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 []
}