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

主要变更:

- 新增 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:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View 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>
)
}

View 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双 RadaraverageScore + passRate
* - MasteryRadarChart双 Radarstudent + classAverage含条件 Legend
*
* 默认配置:
* - PolarGrid: strokeOpacity=0.4
* - PolarAngleAxis: tick fontSize=12
* - PolarRadiusAxis: domain=[0,100], tickFormatter=百分比
* - Radar: stroke/fill 来自 configfillOpacity 可配置
*/
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>
)
}

View 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>
)
}

View 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 个文件几乎逐行相同):
* - GradeTrendChartdataKey=normalizedScore, height=h-[280px]
* - TeacherGradeTrendsdataKey=score, height=h-[200px]
* - StudentGradesCarddataKey=score, height=h-[200px]
* - ChildGradeSummarydataKey=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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

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

View 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>
)
}

View File

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

View File

@@ -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

View 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)
}

View File

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

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

View File

@@ -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}`
}

View File

@@ -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()
})
})

View File

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