feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
90
src/shared/components/charts/chart-card-shell.tsx
Normal file
90
src/shared/components/charts/chart-card-shell.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 图表卡片外壳:统一的 Card + CardHeader + EmptyState + CardContent 结构。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - GradeTrendChart 的 Card + EmptyState 包装
|
||||
* - TeacherGradeTrends 的 Card + EmptyState 包装
|
||||
* - StudentGradesCard 的 Card + EmptyState 包装
|
||||
* - ChildGradeSummary 的 Card + EmptyState 包装
|
||||
* - GradeDistributionChart / ClassComparisonChart 等 BarChart 卡片
|
||||
* - SubjectComparisonChart / MasteryRadarChart 等 RadarChart 卡片
|
||||
*
|
||||
* 结构:Card > CardHeader (icon + title + description) > CardContent (EmptyState | children)。
|
||||
*/
|
||||
interface ChartCardShellProps {
|
||||
/** 卡片标题 */
|
||||
title: string
|
||||
/** 标题下方描述(可为字符串或 ReactNode,例如含动态数值) */
|
||||
description?: ReactNode
|
||||
/** 标题左侧图标(lucide-react 图标等) */
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** 图标额外类名(如 text-primary、text-muted-foreground) */
|
||||
iconClassName?: string
|
||||
/** 标题额外类名(如 text-base font-medium) */
|
||||
titleClassName?: string
|
||||
/** 是否为空状态(为 true 时渲染 EmptyState,否则渲染 children) */
|
||||
isEmpty?: boolean
|
||||
/** 空状态标题 */
|
||||
emptyTitle?: string
|
||||
/** 空状态描述 */
|
||||
emptyDescription?: string
|
||||
/** 空状态图标(默认使用 icon) */
|
||||
emptyIcon?: ComponentType<{ className?: string }>
|
||||
/** 空状态额外类名(如 h-60、h-72) */
|
||||
emptyClassName?: string
|
||||
/** 卡片内容 */
|
||||
children: ReactNode
|
||||
/** Card 额外类名 */
|
||||
className?: string
|
||||
/** CardContent 额外类名 */
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function ChartCardShell({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
titleClassName,
|
||||
isEmpty = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon,
|
||||
emptyClassName,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: ChartCardShellProps) {
|
||||
const EmptyIcon = emptyIcon ?? Icon
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("flex items-center gap-2", titleClassName)}>
|
||||
{Icon ? <Icon className={cn("h-4 w-4", iconClassName)} /> : null}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className={contentClassName}>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={EmptyIcon}
|
||||
title={emptyTitle ?? "No data available"}
|
||||
description={emptyDescription ?? "No data to display."}
|
||||
className={cn("border-none", emptyClassName)}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
143
src/shared/components/charts/comparison-radar-chart.tsx
Normal file
143
src/shared/components/charts/comparison-radar-chart.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
Legend,
|
||||
} from "recharts"
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 对比雷达图:统一的 RadarChart 配置(PolarGrid + PolarAngleAxis + PolarRadiusAxis + ChartTooltip + Radar)。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - SubjectComparisonChart(双 Radar:averageScore + passRate)
|
||||
* - MasteryRadarChart(双 Radar:student + classAverage,含条件 Legend)
|
||||
*
|
||||
* 默认配置:
|
||||
* - PolarGrid: strokeOpacity=0.4
|
||||
* - PolarAngleAxis: tick fontSize=12
|
||||
* - PolarRadiusAxis: domain=[0,100], tickFormatter=百分比
|
||||
* - Radar: stroke/fill 来自 config,fillOpacity 可配置
|
||||
*/
|
||||
export interface RadarSeries {
|
||||
/** 数据字段名 */
|
||||
dataKey: string
|
||||
/** 图例名称 */
|
||||
name: string
|
||||
/** 颜色(CSS 变量或 hsl 值) */
|
||||
color: string
|
||||
/** 填充透明度(默认 0.3) */
|
||||
fillOpacity?: number
|
||||
/** 线宽(默认不设置) */
|
||||
strokeWidth?: number
|
||||
/** 虚线样式(如 "4 4") */
|
||||
strokeDasharray?: string
|
||||
/** 是否条件渲染(为 false 时不渲染该系列) */
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
interface ComparisonRadarChartProps {
|
||||
/** 图表数据 */
|
||||
data: Array<Record<string, string | number>>
|
||||
/** 雷达系列配置 */
|
||||
series: RadarSeries[]
|
||||
/** 角度轴数据字段名 */
|
||||
angleKey: string
|
||||
/** 角度轴刻度格式化(默认不格式化) */
|
||||
angleTickFormatter?: (value: string) => string
|
||||
/** 角度轴字体大小(默认 12) */
|
||||
angleTickFontSize?: number
|
||||
/** 半径轴定义域(默认 [0, 100]) */
|
||||
domain?: [number, number]
|
||||
/** 半径轴刻度数量 */
|
||||
tickCount?: number
|
||||
/** 是否显示 Legend */
|
||||
showLegend?: boolean
|
||||
/** 图表高度类名(默认 "h-[300px]") */
|
||||
heightClassName?: string
|
||||
/** Tooltip 宽度类名(默认 "w-[220px]") */
|
||||
tooltipClassName?: string
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
/** PolarGrid 虚线样式(如 "4 4") */
|
||||
gridStrokeDasharray?: string
|
||||
/** PolarGrid 透明度(默认 0.4) */
|
||||
gridStrokeOpacity?: number
|
||||
}
|
||||
|
||||
export function ComparisonRadarChart({
|
||||
data,
|
||||
series,
|
||||
angleKey,
|
||||
angleTickFormatter,
|
||||
angleTickFontSize = 12,
|
||||
domain = [0, 100],
|
||||
tickCount,
|
||||
showLegend = false,
|
||||
heightClassName = "h-[300px]",
|
||||
tooltipClassName = "w-[220px]",
|
||||
className,
|
||||
gridStrokeDasharray,
|
||||
gridStrokeOpacity = 0.4,
|
||||
}: ComparisonRadarChartProps) {
|
||||
const chartConfig: ChartConfig = {}
|
||||
for (const s of series) {
|
||||
chartConfig[s.dataKey] = {
|
||||
label: s.name,
|
||||
color: s.color,
|
||||
}
|
||||
}
|
||||
|
||||
const visibleSeries = series.filter((s) => s.show !== false)
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className={cn(heightClassName, "w-full", className)}
|
||||
>
|
||||
<RadarChart data={data} outerRadius="75%">
|
||||
<PolarGrid
|
||||
strokeDasharray={gridStrokeDasharray}
|
||||
strokeOpacity={gridStrokeOpacity}
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey={angleKey}
|
||||
tick={{ fontSize: angleTickFontSize }}
|
||||
tickFormatter={angleTickFormatter}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
domain={domain}
|
||||
tickCount={tickCount}
|
||||
tickFormatter={(value: number) => `${value}%`}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent className={tooltipClassName} />} />
|
||||
{showLegend ? <Legend /> : null}
|
||||
{visibleSeries.map((s) => (
|
||||
<Radar
|
||||
key={s.dataKey}
|
||||
name={s.name}
|
||||
dataKey={s.dataKey}
|
||||
stroke={`var(--color-${s.dataKey})`}
|
||||
fill={`var(--color-${s.dataKey})`}
|
||||
fillOpacity={s.fillOpacity ?? 0.3}
|
||||
strokeWidth={s.strokeWidth}
|
||||
strokeDasharray={s.strokeDasharray}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
162
src/shared/components/charts/simple-bar-chart.tsx
Normal file
162
src/shared/components/charts/simple-bar-chart.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Bar, BarChart, CartesianGrid, Legend, XAxis, YAxis, Cell } from "recharts"
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 柱状图:统一的 BarChart 配置(CartesianGrid + XAxis + YAxis + ChartTooltip + Bar)。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - GradeDistributionChart(单 Bar + Cell 分桶着色)
|
||||
* - ClassComparisonChart(多 Bar + Legend)
|
||||
*
|
||||
* 默认配置:
|
||||
* - CartesianGrid: vertical=false, strokeDasharray="4 4", strokeOpacity=0.4
|
||||
* - XAxis: tickLine=false, axisLine=false, tickMargin=8, 默认截断到 8 字符
|
||||
* - YAxis: tickLine=false, axisLine=false
|
||||
* - Bar: radius=[4,4,0,0]
|
||||
*/
|
||||
export interface BarSeries {
|
||||
/** 数据字段名 */
|
||||
dataKey: string
|
||||
/** 图例名称 */
|
||||
name: string
|
||||
/** 颜色(CSS 变量或 hsl 值) */
|
||||
color: string
|
||||
/** 圆角(默认 [4, 4, 0, 0]) */
|
||||
radius?: [number, number, number, number]
|
||||
}
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
/** 图表数据 */
|
||||
data: Array<Record<string, string | number>>
|
||||
/** 柱系列配置(单条或多条) */
|
||||
bars: BarSeries[]
|
||||
/** X 轴数据字段名 */
|
||||
xKey: string
|
||||
/** Y 轴定义域(如 [0, 100];不传则不设置 domain) */
|
||||
yDomain?: [number, number]
|
||||
/** Y 轴是否允许小数(默认 true) */
|
||||
yAllowDecimals?: boolean
|
||||
/** Y 轴刻度格式化(如百分比) */
|
||||
yTickFormatter?: (value: number) => string
|
||||
/** X 轴刻度格式化(默认 "default"=截断到 8 字符;设为 null 则不格式化;传函数则自定义) */
|
||||
xTickFormatter?: ((value: string) => string) | "default" | null
|
||||
/** X 轴截断长度(默认 8) */
|
||||
xTruncateLength?: number
|
||||
/** Y 轴宽度(默认 36) */
|
||||
yWidth?: number
|
||||
/** 图表高度类名(默认 "h-[280px]") */
|
||||
heightClassName?: string
|
||||
/** 图表 margin */
|
||||
margin?: { left: number; right: number; top: number; bottom: number }
|
||||
/** 是否显示 Legend(多 Bar 时建议 true) */
|
||||
showLegend?: boolean
|
||||
/** Tooltip 宽度类名(默认 "w-[200px]") */
|
||||
tooltipClassName?: string
|
||||
/** 自定义 Tooltip formatter(用于自定义 tooltip 内容) */
|
||||
tooltipFormatter?: (payload: unknown) => ReactNode
|
||||
/** 按数据项着色的映射(key = xKey 值, value = 颜色);用于单 Bar 分桶着色 */
|
||||
cellColors?: Record<string, string>
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_X_TRUNCATE_LENGTH = 8
|
||||
|
||||
function makeXTickFormatter(truncateLength: number) {
|
||||
return (value: string): string =>
|
||||
value.length > truncateLength ? `${value.slice(0, truncateLength)}...` : value
|
||||
}
|
||||
|
||||
export function SimpleBarChart({
|
||||
data,
|
||||
bars,
|
||||
xKey,
|
||||
yDomain,
|
||||
yAllowDecimals = true,
|
||||
yTickFormatter,
|
||||
xTickFormatter = "default",
|
||||
xTruncateLength = DEFAULT_X_TRUNCATE_LENGTH,
|
||||
yWidth = 36,
|
||||
heightClassName = "h-[280px]",
|
||||
margin = { left: 8, right: 8, top: 8, bottom: 8 },
|
||||
showLegend = false,
|
||||
tooltipClassName = "w-[200px]",
|
||||
tooltipFormatter,
|
||||
cellColors,
|
||||
className,
|
||||
}: SimpleBarChartProps) {
|
||||
const chartConfig: ChartConfig = {}
|
||||
for (const b of bars) {
|
||||
chartConfig[b.dataKey] = {
|
||||
label: b.name,
|
||||
color: b.color,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedXTickFormatter =
|
||||
xTickFormatter === null
|
||||
? undefined
|
||||
: xTickFormatter === "default"
|
||||
? makeXTickFormatter(xTruncateLength)
|
||||
: xTickFormatter
|
||||
|
||||
const hasCellColors = !!cellColors && bars.length === 1
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
|
||||
<BarChart data={data} margin={margin}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={resolvedXTickFormatter}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
allowDecimals={yAllowDecimals}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={yTickFormatter}
|
||||
width={yWidth}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
tooltipFormatter ? (
|
||||
<ChartTooltipContent className={tooltipClassName} formatter={tooltipFormatter} />
|
||||
) : (
|
||||
<ChartTooltipContent className={tooltipClassName} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{showLegend ? <Legend /> : null}
|
||||
{bars.map((b) => (
|
||||
<Bar
|
||||
key={b.dataKey}
|
||||
dataKey={b.dataKey}
|
||||
fill={`var(--color-${b.dataKey})`}
|
||||
radius={b.radius ?? [4, 4, 0, 0]}
|
||||
>
|
||||
{hasCellColors && cellColors
|
||||
? data.map((entry) => {
|
||||
const cellKey = String(entry[xKey])
|
||||
return <Cell key={cellKey} fill={cellColors[cellKey]} />
|
||||
})
|
||||
: null}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
153
src/shared/components/charts/trend-line-chart.tsx
Normal file
153
src/shared/components/charts/trend-line-chart.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 趋势折线图:统一的 LineChart 配置(CartesianGrid + XAxis + YAxis + ChartTooltip + Line)。
|
||||
*
|
||||
* 覆盖以下重复模式(4 个文件几乎逐行相同):
|
||||
* - GradeTrendChart(dataKey=normalizedScore, height=h-[280px])
|
||||
* - TeacherGradeTrends(dataKey=score, height=h-[200px])
|
||||
* - StudentGradesCard(dataKey=score, height=h-[200px])
|
||||
* - ChildGradeSummary(dataKey=score, xKey=date, height=h-[160px])
|
||||
*
|
||||
* 默认配置:
|
||||
* - CartesianGrid: vertical=false, strokeDasharray="4 4", strokeOpacity=0.4
|
||||
* - XAxis: tickLine=false, axisLine=false, tickMargin=8, 默认截断到 10 字符
|
||||
* - YAxis: domain=[0,100], tickLine=false, axisLine=false, 默认百分比格式化, width=36
|
||||
* - ChartTooltip: cursor 虚线, content=ChartTooltipContent indicator="line" labelKey="fullTitle"
|
||||
* - Line: type="monotone", strokeWidth=2
|
||||
*/
|
||||
export interface TrendLineSeries {
|
||||
/** 数据字段名(对应 data 中的 key) */
|
||||
dataKey: string
|
||||
/** 图例名称 */
|
||||
name: string
|
||||
/** 颜色(CSS 变量或 hsl 值,如 "hsl(var(--primary))") */
|
||||
color: string
|
||||
/** 数据点半径(默认 3) */
|
||||
dotRadius?: number
|
||||
/** 激活数据点半径(默认 5) */
|
||||
activeDotRadius?: number
|
||||
}
|
||||
|
||||
interface TrendLineChartProps {
|
||||
/** 图表数据 */
|
||||
data: Array<Record<string, string | number>>
|
||||
/** 折线系列配置(支持单条或多条) */
|
||||
series: TrendLineSeries[]
|
||||
/** X 轴数据字段名(默认 "title") */
|
||||
xKey?: string
|
||||
/** Y 轴定义域(默认 [0, 100]) */
|
||||
yDomain?: [number, number]
|
||||
/** Y 轴刻度格式化(默认百分比 `${value}%`) */
|
||||
yTickFormatter?: (value: number) => string
|
||||
/** X 轴刻度格式化(默认截断到 10 字符;设为 null 则不格式化) */
|
||||
xTickFormatter?: ((value: string) => string) | null
|
||||
/** 图表高度类名(默认 "h-[280px]") */
|
||||
heightClassName?: string
|
||||
/** 图表 margin(默认 { left: 8, right: 8, top: 8, bottom: 8 }) */
|
||||
margin?: { left: number; right: number; top: number; bottom: number }
|
||||
/** Y 轴宽度(默认 36) */
|
||||
yWidth?: number
|
||||
/** Tooltip 内容宽度类名(默认 "w-[220px]") */
|
||||
tooltipClassName?: string
|
||||
/** Tooltip labelKey(默认 "fullTitle") */
|
||||
tooltipLabelKey?: string
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_X_TICK_TRUNCATE_LENGTH = 10
|
||||
|
||||
function defaultXTickFormatter(value: string): string {
|
||||
return value.length > DEFAULT_X_TICK_TRUNCATE_LENGTH
|
||||
? `${value.slice(0, DEFAULT_X_TICK_TRUNCATE_LENGTH)}...`
|
||||
: value
|
||||
}
|
||||
|
||||
function defaultYTickFormatter(value: number): string {
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
export function TrendLineChart({
|
||||
data,
|
||||
series,
|
||||
xKey = "title",
|
||||
yDomain = [0, 100],
|
||||
yTickFormatter = defaultYTickFormatter,
|
||||
xTickFormatter = defaultXTickFormatter,
|
||||
heightClassName = "h-[280px]",
|
||||
margin = { left: 8, right: 8, top: 8, bottom: 8 },
|
||||
yWidth = 36,
|
||||
tooltipClassName = "w-[220px]",
|
||||
tooltipLabelKey = "fullTitle",
|
||||
className,
|
||||
}: TrendLineChartProps) {
|
||||
const chartConfig: ChartConfig = {}
|
||||
for (const s of series) {
|
||||
chartConfig[s.dataKey] = {
|
||||
label: s.name,
|
||||
color: s.color,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
|
||||
<LineChart data={data} margin={margin}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={xTickFormatter ?? undefined}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={yTickFormatter}
|
||||
width={yWidth}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelKey={tooltipLabelKey}
|
||||
className={tooltipClassName}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{series.map((s) => (
|
||||
<Line
|
||||
key={s.dataKey}
|
||||
dataKey={s.dataKey}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${s.dataKey})`}
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: `var(--color-${s.dataKey})`,
|
||||
r: s.dotRadius ?? 3,
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
activeDot={{ r: s.activeDotRadius ?? 5, strokeWidth: 0 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
137
src/shared/components/question/question-bank-filters.tsx
Normal file
137
src/shared/components/question/question-bank-filters.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 题库筛选栏:统一的搜索 + 题型 + 难度筛选。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - exam-assembly.tsx 内嵌的题库筛选(compact 布局)
|
||||
* - question-bank-picker.tsx 内嵌的题库筛选(原生 HTML,需迁移到 shadcn)
|
||||
*
|
||||
* 注意:状态管理方式由调用方自行处理(exam-assembly 用 useState,
|
||||
* picker 用 useState 对象),本组件只负责 UI 渲染。
|
||||
*/
|
||||
interface QuestionBankFiltersProps {
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
type: string
|
||||
onTypeChange: (value: string) => void
|
||||
difficulty: string
|
||||
onDifficultyChange: (value: string) => void
|
||||
/** 布局变体:default=exam-assembly 风格, compact=紧凑内嵌 */
|
||||
layout?: "default" | "compact"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", label: "All Types" },
|
||||
{ value: "single_choice", label: "Single Choice" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice" },
|
||||
{ value: "judgment", label: "True/False" },
|
||||
{ value: "text", label: "Short Answer" },
|
||||
]
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "1", label: "Lvl 1" },
|
||||
{ value: "2", label: "Lvl 2" },
|
||||
{ value: "3", label: "Lvl 3" },
|
||||
{ value: "4", label: "Lvl 4" },
|
||||
{ value: "5", label: "Lvl 5" },
|
||||
]
|
||||
|
||||
export function QuestionBankFilters({
|
||||
search,
|
||||
onSearchChange,
|
||||
type,
|
||||
onTypeChange,
|
||||
difficulty,
|
||||
onDifficultyChange,
|
||||
layout = "default",
|
||||
className,
|
||||
}: QuestionBankFiltersProps) {
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search by content..."
|
||||
className="w-full"
|
||||
inputClassName="h-9 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={type} onValueChange={onTypeChange}>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs bg-background">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={onDifficultyChange}>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs bg-background">
|
||||
<SelectValue placeholder="Diff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", className)}>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search by content..."
|
||||
className="flex-1"
|
||||
inputClassName="h-9 text-sm"
|
||||
/>
|
||||
<Select value={type} onValueChange={onTypeChange}>
|
||||
<SelectTrigger className="w-[140px] h-9 text-sm bg-background">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={onDifficultyChange}>
|
||||
<SelectTrigger className="w-[100px] h-9 text-sm bg-background">
|
||||
<SelectValue placeholder="Diff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/shared/components/schedule/schedule-list.tsx
Normal file
112
src/shared/components/schedule/schedule-list.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Clock, MapPin } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 课表列表项:统一的课程 + 时间 + 地点 + 班级徽章渲染。
|
||||
*
|
||||
* 覆盖以下重复模式(3 个文件逐行复制):
|
||||
* - StudentTodayScheduleCard 的列表项
|
||||
* - ChildScheduleCard 的列表项
|
||||
* - StudentScheduleView 的列表项
|
||||
*
|
||||
* 数据结构:{ id, course, startTime, endTime, location?, className }
|
||||
*/
|
||||
export interface ScheduleListItemData {
|
||||
id: string
|
||||
course: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ScheduleListItemProps {
|
||||
item: ScheduleListItemData
|
||||
/** 布局变体:"separator"=分隔线风格, "card"=卡片风格 */
|
||||
variant?: "separator" | "card"
|
||||
/** 右侧自定义内容(默认渲染 className Badge) */
|
||||
trailing?: ReactNode
|
||||
/** 额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ScheduleListItem({
|
||||
item,
|
||||
variant = "separator",
|
||||
trailing,
|
||||
className,
|
||||
}: ScheduleListItemProps) {
|
||||
const wrapperClass =
|
||||
variant === "separator"
|
||||
? "flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
: "flex items-start justify-between gap-3 rounded-md border bg-card p-3"
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn(wrapperClass, className)}>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="font-medium leading-none truncate">{item.course}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||
<div className="inline-flex items-center">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<span className="tabular-nums">
|
||||
{item.startTime}–{item.endTime}
|
||||
</span>
|
||||
</div>
|
||||
{item.location ? (
|
||||
<div className="inline-flex items-center min-w-0">
|
||||
<MapPin className="mr-1 h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{item.location}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{trailing !== undefined ? (
|
||||
trailing
|
||||
) : item.className ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{item.className}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScheduleListProps {
|
||||
items: ScheduleListItemData[]
|
||||
/** 布局变体:"separator"=分隔线风格, "card"=卡片风格 */
|
||||
variant?: "separator" | "card"
|
||||
/** 列表容器间距类名(默认 "space-y-4") */
|
||||
spacingClassName?: string
|
||||
/** 右侧自定义内容渲染函数(默认渲染 className Badge) */
|
||||
renderTrailing?: (item: ScheduleListItemData) => ReactNode
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ScheduleList({
|
||||
items,
|
||||
variant = "separator",
|
||||
spacingClassName = "space-y-4",
|
||||
renderTrailing,
|
||||
className,
|
||||
}: ScheduleListProps) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn(spacingClassName, className)}>
|
||||
{items.map((item) => (
|
||||
<ScheduleListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
variant={variant}
|
||||
trailing={renderTrailing?.(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/shared/components/ui/chip-nav.tsx
Normal file
78
src/shared/components/ui/chip-nav.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* Chip 导航组:用于通过 URL search params 切换筛选维度的 chip/button 组。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - stats-class-selector(班级 + 学科 chip 组)
|
||||
* - attendance-stats-class-selector(班级 chip 组)
|
||||
* - analytics-filters(班级 + 学科 + 年级 chip 组)
|
||||
*
|
||||
* 每个 chip 是一个 Link,点击后通过 buildHref 构造的 URL 跳转。
|
||||
*/
|
||||
interface ChipNavOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ChipNavProps {
|
||||
/** 可选项列表 */
|
||||
options: ChipNavOption[]
|
||||
/** 当前选中项 id */
|
||||
currentId: string
|
||||
/** 根据选项 id 构造跳转 href */
|
||||
buildHref: (id: string) => string
|
||||
/** chip 尺寸:"sm"(默认)或 "xs"(更紧凑) */
|
||||
size?: "sm" | "xs"
|
||||
/** 可选的 "全部" 选项,放在最前 */
|
||||
allOption?: { id: string; label: string }
|
||||
/** 额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChipNav({
|
||||
options,
|
||||
currentId,
|
||||
buildHref,
|
||||
size = "sm",
|
||||
allOption,
|
||||
className,
|
||||
}: ChipNavProps) {
|
||||
const sizeClass =
|
||||
size === "xs" ? "px-2.5 py-1 text-xs" : "px-3 py-1.5 text-sm"
|
||||
|
||||
const chipClass = (active: boolean): string =>
|
||||
cn(
|
||||
"rounded-md border transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
sizeClass,
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2", className)}>
|
||||
{allOption ? (
|
||||
<Link
|
||||
key={allOption.id}
|
||||
href={buildHref(allOption.id)}
|
||||
className={chipClass(currentId === allOption.id)}
|
||||
>
|
||||
{allOption.label}
|
||||
</Link>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
<Link
|
||||
key={option.id}
|
||||
href={buildHref(option.id)}
|
||||
className={chipClass(currentId === option.id)}
|
||||
>
|
||||
{option.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/shared/components/ui/filter-bar.tsx
Normal file
124
src/shared/components/ui/filter-bar.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 筛选栏容器:统一的筛选控件布局壳。
|
||||
*
|
||||
* 覆盖以下重复模式(6+ 个文件共享相同布局):
|
||||
* - ExamFilters: 搜索 + Select + Reset
|
||||
* - TextbookFilters: 搜索 + Select + Reset
|
||||
* - QuestionFilters: 搜索 + Select + Select + Reset
|
||||
* - AuditLogFilters: Select + Input + Select + date + date + Reset
|
||||
* - LoginLogFilters: Select + Select + date + date + Reset
|
||||
*
|
||||
* 布局:移动端纵向排列,桌面端横向排列(可选 wrap/justify-between)。
|
||||
* 注意:URL 状态管理方式(nuqs/router/callback)由各模块自行处理,
|
||||
* 本组件只负责布局壳和 Reset 按钮的视觉统一。
|
||||
*/
|
||||
interface FilterBarProps {
|
||||
/** 筛选控件(搜索框、Select、日期等) */
|
||||
children: ReactNode
|
||||
/** 是否有激活的筛选条件(控制 Reset 按钮显示) */
|
||||
hasFilters?: boolean
|
||||
/** 重置回调 */
|
||||
onReset?: () => void
|
||||
/** 布局变体:默认=横向, wrap=允许换行, between=两端对齐 */
|
||||
layout?: "default" | "wrap" | "between"
|
||||
/** 容器间距类名(默认 "gap-3") */
|
||||
gapClassName?: string
|
||||
/** 容器额外类名 */
|
||||
className?: string
|
||||
/** Reset 按钮额外类名(如 h-8 px-2) */
|
||||
resetClassName?: string
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
children,
|
||||
hasFilters = false,
|
||||
onReset,
|
||||
layout = "default",
|
||||
gapClassName = "gap-3",
|
||||
className,
|
||||
resetClassName,
|
||||
}: FilterBarProps) {
|
||||
const layoutClass =
|
||||
layout === "wrap"
|
||||
? "md:flex-wrap"
|
||||
: layout === "between"
|
||||
? "md:justify-between"
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
gapClassName,
|
||||
"md:flex-row md:items-center",
|
||||
layoutClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{hasFilters && onReset ? (
|
||||
<FilterResetButton onClick={onReset} className={resetClassName} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选栏搜索框:带 Search 图标的 Input。
|
||||
*
|
||||
* 覆盖 exam-filters/textbook-filters/question-filters 中重复的搜索框模式。
|
||||
*/
|
||||
interface FilterSearchInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
export function FilterSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search...",
|
||||
className,
|
||||
inputClassName,
|
||||
}: FilterSearchInputProps) {
|
||||
return (
|
||||
<div className={cn("relative w-full md:w-80", className)}>
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className={cn("pl-9 bg-background border-muted-foreground/20", inputClassName)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterResetButtonProps {
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterResetButton({ onClick, className }: FilterResetButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={cn("h-10 px-3", className)}
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
44
src/shared/components/ui/page-header.tsx
Normal file
44
src/shared/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 页面头部:统一的标题 + 描述 + 操作区域布局。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - AdminDashboardView 内联头部
|
||||
* - Profile 页面内联头部
|
||||
* - SettingsView 内联头部
|
||||
* - Security 页面内联头部(带图标)
|
||||
*
|
||||
* 结构:左侧标题 + 描述,右侧操作区域(响应式:移动端纵向,桌面端横向)。
|
||||
*/
|
||||
interface PageHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 标题下方描述文本 */
|
||||
description?: string
|
||||
/** 标题左侧图标组件(lucide-react 图标等) */
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** 右侧操作区域(按钮、徽章等) */
|
||||
actions?: ReactNode
|
||||
/** 额外类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, icon: Icon, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col justify-between gap-4 md:flex-row md:items-center", className)}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon ? <Icon className="h-7 w-7 text-muted-foreground" /> : null}
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
</div>
|
||||
{description ? (
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/shared/components/ui/stat-card.tsx
Normal file
95
src/shared/components/ui/stat-card.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* 统计卡片:用于仪表盘、洞察页等场景的单张统计卡片。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - TeacherStats / StudentStatsGrid(带图标 + 描述 + 跳转)
|
||||
* - AdminDashboardView KpiCard(带图标,无描述)
|
||||
* - insights / diagnostic / student-summary 页面内联卡片(无图标,带描述)
|
||||
*/
|
||||
interface StatCardProps {
|
||||
/** 卡片标题(统计项名称) */
|
||||
title: string
|
||||
/** 统计数值 */
|
||||
value: string | number
|
||||
/** 图标组件(Lucide 图标),传入组件类型而非实例 */
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
/** 数值下方描述文本 */
|
||||
description?: string
|
||||
/** 图标颜色类名(如 "text-amber-500") */
|
||||
color?: string
|
||||
/** 是否高亮(amber 边框 + 背景) */
|
||||
highlight?: boolean
|
||||
/** 点击跳转链接,传入则包裹 Link */
|
||||
href?: string
|
||||
/** 加载态,显示骨架屏 */
|
||||
isLoading?: boolean
|
||||
/** 数值自定义类名(如 "text-red-500") */
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
function StatCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="mb-2 h-8 w-[60px]" />
|
||||
<Skeleton className="h-3 w-[140px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
description,
|
||||
color,
|
||||
highlight = false,
|
||||
href,
|
||||
isLoading = false,
|
||||
valueClassName,
|
||||
}: StatCardProps) {
|
||||
if (isLoading) {
|
||||
return <StatCardSkeleton />
|
||||
}
|
||||
|
||||
const card = (
|
||||
<Card
|
||||
className={cn(
|
||||
highlight && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{Icon ? <Icon className={cn("h-4 w-4 text-muted-foreground", color)} /> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn("text-2xl font-bold", valueClassName)}>{value}</div>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block transition-transform hover:-translate-y-1">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
38
src/shared/components/ui/stat-item.tsx
Normal file
38
src/shared/components/ui/stat-item.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 统计项:用于统计面板内部网格中的紧凑统计单元。
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - AttendanceStatsCard 内部 StatItem
|
||||
* - GradeStatsCard 内部 StatItem
|
||||
*
|
||||
* 结构:rounded-lg border bg-card 容器,含 label + icon + value + hint
|
||||
*/
|
||||
interface StatItemProps {
|
||||
/** 统计项标签 */
|
||||
label: string
|
||||
/** 统计数值 */
|
||||
value: string | number
|
||||
/** 图标节点(如 <Users className="h-4 w-4" />) */
|
||||
icon?: React.ReactNode
|
||||
/** 底部提示文本 */
|
||||
hint?: string
|
||||
/** 数值自定义类名(如 "text-red-500") */
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export function StatItem({ label, value, icon, hint, valueClassName }: StatItemProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{icon ? <span className="text-muted-foreground">{icon}</span> : null}
|
||||
</div>
|
||||
<span className={cn("text-2xl font-bold", valueClassName)}>{value}</span>
|
||||
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
import type { Permission, Role } from "@/shared/types/permissions"
|
||||
|
||||
/**
|
||||
* Client-side permission hook.
|
||||
*
|
||||
* Hydration safety: `useSession()` returns `null`/`loading` on the server and
|
||||
* during the first client render. Callers should gate permission-dependent UI
|
||||
* behind `status === "authenticated"` (returned by `useSession`) or render a
|
||||
* skeleton/placeholder while loading to avoid hydration mismatches.
|
||||
*/
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
const permissions = (session?.user?.permissions ?? []) as Permission[]
|
||||
const roles = (session?.user?.roles ?? []) as string[]
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission)
|
||||
const permissions = useMemo<Permission[]>(
|
||||
() => session?.user?.permissions ?? [],
|
||||
[session?.user?.permissions]
|
||||
)
|
||||
const roles = useMemo<Role[]>(
|
||||
() => session?.user?.roles ?? [],
|
||||
[session?.user?.roles]
|
||||
)
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: Permission): boolean => permissions.includes(permission),
|
||||
[permissions]
|
||||
)
|
||||
const hasAnyPermission = useCallback(
|
||||
(...perms: Permission[]): boolean => perms.some((p) => permissions.includes(p)),
|
||||
[permissions]
|
||||
)
|
||||
const hasAllPermissions = useCallback(
|
||||
(...perms: Permission[]): boolean => perms.every((p) => permissions.includes(p)),
|
||||
[permissions]
|
||||
)
|
||||
const hasRole = useCallback(
|
||||
(role: Role): boolean => roles.includes(role),
|
||||
[roles]
|
||||
)
|
||||
|
||||
return {
|
||||
permissions,
|
||||
roles,
|
||||
status,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
hasRole,
|
||||
}
|
||||
|
||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
||||
return perms.some((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
||||
return perms.every((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasRole = (role: string): boolean => {
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||
import type { Permission, DataScope, AuthContext, Role } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
grades,
|
||||
parentStudentRelations,
|
||||
@@ -11,7 +12,9 @@ import { getSession } from "@/shared/lib/session"
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
super(
|
||||
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
|
||||
)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +29,7 @@ export async function getAuthContext(): Promise<AuthContext> {
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
// Prefer session data (already resolved in JWT callback)
|
||||
const roleNames = (session.user.roles ?? []) as string[]
|
||||
const roleNames = (session.user.roles ?? []) as Role[]
|
||||
const permissions = (session.user.permissions ?? []) as Permission[]
|
||||
|
||||
// Resolve data scope from DB (not cached in JWT since it can change)
|
||||
@@ -61,7 +64,7 @@ export async function checkPermission(
|
||||
* Resolve the data scope for a user based on their roles.
|
||||
* Queries the DB for resource ownership information.
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
|
||||
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
|
||||
// Admin sees everything
|
||||
if (roleNames.includes("admin")) {
|
||||
return { type: "all" }
|
||||
@@ -111,8 +114,17 @@ async function resolveDataScope(userId: string, roleNames: string[]): Promise<Da
|
||||
}
|
||||
|
||||
// Student: can see data from their enrolled classes
|
||||
// Pre-resolve classIds here to avoid N+1 queries in data-access layer
|
||||
if (roleNames.includes("student")) {
|
||||
return { type: "class_members" }
|
||||
const enrolledClasses = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.studentId, userId))
|
||||
|
||||
return {
|
||||
type: "class_members",
|
||||
classIds: enrolledClasses.map((c) => c.classId),
|
||||
}
|
||||
}
|
||||
|
||||
// Parent: can see their children's data
|
||||
|
||||
47
src/shared/lib/download.ts
Normal file
47
src/shared/lib/download.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 客户端文件下载工具
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - grades/export-button.tsx 中的 downloadBase64File
|
||||
* - users/user-import-dialog.tsx 中的 downloadBase64File
|
||||
* - audit/audit-log-export-button.tsx 中的 Blob 下载逻辑
|
||||
*
|
||||
* 注意:仅在客户端使用(依赖 document、URL.createObjectURL 等 API)。
|
||||
*/
|
||||
|
||||
const EXCEL_MIME_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
/**
|
||||
* 将 base64 编码的数据下载为文件
|
||||
* @param base64 base64 编码的文件内容
|
||||
* @param filename 下载文件名
|
||||
* @param mimeType MIME 类型,默认为 Excel
|
||||
*/
|
||||
export function downloadBase64File(
|
||||
base64: string,
|
||||
filename: string,
|
||||
mimeType: string = EXCEL_MIME_TYPE
|
||||
): void {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mimeType })
|
||||
downloadBlob(blob, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Blob 数据下载为文件
|
||||
* @param blob Blob 数据
|
||||
* @param filename 下载文件名
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
import { Permissions, type Permission, type Role } from "@/shared/types/permissions"
|
||||
|
||||
// Role → Permission mapping
|
||||
// New roles only need to add an entry here + seed the DB
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
@@ -59,6 +59,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.LESSON_PLAN_UPDATE,
|
||||
Permissions.LESSON_PLAN_DELETE,
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
Permissions.FILE_UPLOAD,
|
||||
Permissions.FILE_READ,
|
||||
Permissions.FILE_DELETE,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -116,6 +119,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.GRADE_RECORD_READ,
|
||||
Permissions.COURSE_PLAN_READ,
|
||||
Permissions.ATTENDANCE_READ,
|
||||
Permissions.MESSAGE_SEND,
|
||||
Permissions.MESSAGE_READ,
|
||||
Permissions.MESSAGE_DELETE,
|
||||
Permissions.ELECTIVE_SELECT,
|
||||
@@ -208,7 +212,7 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
/**
|
||||
* Merge permissions from all roles (deduplicated)
|
||||
*/
|
||||
export function resolvePermissions(roleNames: string[]): Permission[] {
|
||||
export function resolvePermissions(roleNames: Role[]): Permission[] {
|
||||
const set = new Set<Permission>()
|
||||
for (const name of roleNames) {
|
||||
const perms = ROLE_PERMISSIONS[name] ?? []
|
||||
|
||||
9
src/shared/lib/search-params.ts
Normal file
9
src/shared/lib/search-params.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Search params utility for Next.js pages.
|
||||
*
|
||||
* Re-exports from utils.ts for consistency with admin pages.
|
||||
* Next.js 15+ passes `searchParams` as a Promise. Values may be string,
|
||||
* string[], or undefined. This helper normalizes access to a single value.
|
||||
*/
|
||||
|
||||
export { getSearchParam as getParam, type SearchParams } from "./utils"
|
||||
@@ -12,3 +12,54 @@ export function formatDate(date: string | Date, locale: string = "zh-CN") {
|
||||
day: "numeric",
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
/** Next.js App Router 搜索参数类型 */
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
/** 从 SearchParams 中安全提取单个字符串值 */
|
||||
export function getSearchParam(params: SearchParams, key: string): string | undefined {
|
||||
const v = params[key]
|
||||
if (typeof v === "string") return v
|
||||
if (Array.isArray(v)) return v[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 格式化数字,null/undefined/非有限数返回 "-" */
|
||||
export function formatNumber(v: number | null | undefined, digits = 1): string {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return "-"
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从姓名生成头像占位用的首字母(最多 2 个字符)。
|
||||
* 用于 AvatarFallback 组件。
|
||||
* - 含空格的姓名:取各单词首字母拼接(如 "John Doe" -> "JD")
|
||||
* - 无空格的姓名:取前 2 个字符(如 "张三" -> "张三")
|
||||
* - 空值:返回 "U"(User 通用占位)
|
||||
*/
|
||||
export function getInitials(name: string | null | undefined): string {
|
||||
if (!name) return "U"
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return "U"
|
||||
if (trimmed.includes(" ")) {
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
return trimmed.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为文件名安全的 YYYY-MM-DD 格式。
|
||||
* 用于导出文件名(如 `grades_export_2026-06-20.xlsx`)。
|
||||
* @param d 日期对象,默认为当前时间
|
||||
*/
|
||||
export function formatDateForFile(d: Date = new Date()): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { ActionState } from "./action-state"
|
||||
|
||||
describe("ActionState", () => {
|
||||
it("should create a success state", () => {
|
||||
describe("ActionState 类型构造", () => {
|
||||
it("should create a success state with data", () => {
|
||||
const state: ActionState<string> = {
|
||||
success: true,
|
||||
message: "Operation succeeded",
|
||||
@@ -12,7 +12,7 @@ describe("ActionState", () => {
|
||||
expect(state.data).toBe("result")
|
||||
})
|
||||
|
||||
it("should create an error state", () => {
|
||||
it("should create an error state with field-level errors", () => {
|
||||
const state: ActionState = {
|
||||
success: false,
|
||||
message: "Operation failed",
|
||||
@@ -22,7 +22,7 @@ describe("ActionState", () => {
|
||||
expect(state.errors).toBeDefined()
|
||||
})
|
||||
|
||||
it("should create a void state", () => {
|
||||
it("should create a void state without data", () => {
|
||||
const state: ActionState = {
|
||||
success: true,
|
||||
message: "Done",
|
||||
@@ -30,4 +30,38 @@ describe("ActionState", () => {
|
||||
expect(state.success).toBe(true)
|
||||
expect(state.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow multiple errors per field", () => {
|
||||
const state: ActionState = {
|
||||
success: false,
|
||||
errors: {
|
||||
email: ["必填", "格式不正确"],
|
||||
password: ["至少 8 位"],
|
||||
},
|
||||
}
|
||||
expect(state.errors?.email).toHaveLength(2)
|
||||
expect(state.errors?.password).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should allow falsy data values (0, empty string, null)", () => {
|
||||
const zeroState: ActionState<number> = { success: true, data: 0 }
|
||||
expect(zeroState.data).toBe(0)
|
||||
|
||||
const emptyState: ActionState<string> = { success: true, data: "" }
|
||||
expect(emptyState.data).toBe("")
|
||||
|
||||
const nullState: ActionState<null> = { success: true, data: null }
|
||||
expect(nullState.data).toBeNull()
|
||||
})
|
||||
|
||||
it("should allow empty message", () => {
|
||||
const state: ActionState = { success: true, message: "" }
|
||||
expect(state.message).toBe("")
|
||||
})
|
||||
|
||||
it("should allow success state without message", () => {
|
||||
const state: ActionState<string> = { success: true, data: "result" }
|
||||
expect(state.message).toBeUndefined()
|
||||
expect(state.errors).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
// Permission definitions: resource:action naming convention
|
||||
// Used by requirePermission() on server and usePermission() on client
|
||||
|
||||
/**
|
||||
* All role names recognized by the system.
|
||||
* Used to type-check `AuthContext.roles`, `ROLE_PERMISSIONS` keys, and JWT/session payloads.
|
||||
*/
|
||||
export type Role =
|
||||
| "admin"
|
||||
| "teacher"
|
||||
| "student"
|
||||
| "parent"
|
||||
| "grade_head"
|
||||
| "teaching_head"
|
||||
|
||||
export const Permissions = {
|
||||
// Exam
|
||||
EXAM_CREATE: "exam:create",
|
||||
@@ -41,6 +53,8 @@ export const Permissions = {
|
||||
SCHOOL_MANAGE: "school:manage",
|
||||
GRADE_MANAGE: "grade:manage",
|
||||
USER_MANAGE: "user:manage",
|
||||
|
||||
// User (self-service)
|
||||
/** Self-service profile update (all authenticated roles) */
|
||||
USER_PROFILE_UPDATE: "user:profile_update",
|
||||
|
||||
@@ -103,10 +117,27 @@ export const Permissions = {
|
||||
LESSON_PLAN_UPDATE: "lesson_plan:update",
|
||||
LESSON_PLAN_DELETE: "lesson_plan:delete",
|
||||
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
|
||||
} as const
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
|
||||
const VALID_ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"teacher",
|
||||
"student",
|
||||
"parent",
|
||||
"grade_head",
|
||||
"teaching_head",
|
||||
]
|
||||
|
||||
/**
|
||||
* Type guard: narrows `string` to `Role`.
|
||||
* Use to filter role names coming from the DB or external sources.
|
||||
*/
|
||||
export function isRole(value: string): value is Role {
|
||||
return (VALID_ROLES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Data scope for row-level security.
|
||||
*
|
||||
@@ -114,6 +145,7 @@ export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
* - `all`: no filtering (admin).
|
||||
* - `owned`: only rows created by the user.
|
||||
* - `class_members`: rows visible to members of the user's enrolled classes (student).
|
||||
* `classIds` is pre-resolved by `resolveDataScope` to avoid N+1 queries downstream.
|
||||
* - `grade_managed`: rows within grades the user manages (grade_head / teaching_head).
|
||||
* - `class_taught`: rows within classes the user teaches (teacher).
|
||||
* - `children`: rows belonging to the user's children (parent).
|
||||
@@ -121,7 +153,7 @@ export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
export type DataScope =
|
||||
| { type: "all" }
|
||||
| { type: "owned"; userId: string }
|
||||
| { type: "class_members" }
|
||||
| { type: "class_members"; classIds: string[] }
|
||||
| { type: "grade_managed"; gradeIds: string[] }
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
||||
| { type: "children"; childrenIds: string[] }
|
||||
@@ -130,13 +162,13 @@ export type DataScope =
|
||||
* Authentication context for the current request.
|
||||
*
|
||||
* - `userId`: the authenticated user's id.
|
||||
* - `roles`: all role names assigned to the user.
|
||||
* - `roles`: all role names assigned to the user (typed as `Role[]` to catch typos at compile time).
|
||||
* - `permissions`: the merged set of permission strings granted to the user.
|
||||
* - `dataScope`: the row-level security scope used to filter queries.
|
||||
*/
|
||||
export interface AuthContext {
|
||||
userId: string
|
||||
roles: string[]
|
||||
roles: Role[]
|
||||
permissions: Permission[]
|
||||
dataScope: DataScope
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user