feat(shared): add UI components, hooks, form fields, and action utils

- Add UI components: confirm-delete-dialog, empty-table-row, list-pagination, pagination, status-badge

- Add form-fields directory for reusable form field components

- Add hooks: use-action-mutation, use-action-query for server action integration

- Add action-utils lib for action state helpers

- Update a11y components, charts, global-search, onboarding-gate, question components

- Update UI components: chip-nav, filter-bar, page-header, stat-card, stat-item, switch, table

- Update hooks: use-action-with-toast, use-aria-live, use-debounce, use-local-storage, use-media-query, use-permission

- Update lib: a11y, ai, audit-logger, auth-guard, bcrypt-utils, change-logger, download, excel, file-storage, http-utils, login-logger, password-policy, password-security-service, permissions, rate-limit, role-utils, search-params, session, storage-provider

- Update types: action-state, permissions

- Update i18n messages (en, zh-CN) for dashboard, diagnostic, grades, lesson-preparation, settings
This commit is contained in:
SpecialX
2026-06-23 17:38:14 +08:00
parent 9ceb2b7b67
commit c4d3433cc9
25 changed files with 1986 additions and 28 deletions

View File

@@ -46,6 +46,8 @@ interface ChartCardShellProps {
className?: string
/** CardContent 额外类名 */
contentClassName?: string
/** 卡片头部右侧操作区(如"查看全部"链接) */
action?: ReactNode
}
export function ChartCardShell({
@@ -62,15 +64,19 @@ export function ChartCardShell({
children,
className,
contentClassName,
action,
}: 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>
<div className="flex items-center justify-between">
<CardTitle className={cn("flex items-center gap-2", titleClassName)}>
{Icon ? <Icon className={cn("h-4 w-4", iconClassName)} /> : null}
{title}
</CardTitle>
{action}
</div>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent className={contentClassName}>

View File

@@ -66,6 +66,8 @@ interface SimpleBarChartProps {
tooltipFormatter?: (payload: unknown) => ReactNode
/** 按数据项着色的映射key = xKey 值, value = 颜色);用于单 Bar 分桶着色 */
cellColors?: Record<string, string>
/** 自定义 SVG defs如 patterns、gradients渲染在 BarChart 内部 */
defs?: ReactNode
/** 容器额外类名 */
className?: string
}
@@ -93,6 +95,7 @@ export function SimpleBarChart({
tooltipClassName = "w-[200px]",
tooltipFormatter,
cellColors,
defs,
className,
}: SimpleBarChartProps) {
const chartConfig: ChartConfig = {}
@@ -115,6 +118,7 @@ export function SimpleBarChart({
return (
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
<BarChart data={data} margin={margin}>
{defs}
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey={xKey}

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import {
type Control,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
export interface SelectOption {
value: string
label: string
}
export interface SelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
/** react-hook-form control 实例 */
control: Control<TFieldValues>
/** 字段路径 */
name: TName
/** 标签文本 */
label: string
/** 占位符 */
placeholder?: string
/** 描述文本 */
description?: React.ReactNode
/** 选项列表 */
options: SelectOption[]
/** 是否禁用 */
disabled?: boolean
/** FormItem 的 className */
itemClassName?: string
/** FormLabel 右侧的额外节点(如"新建"按钮) */
labelSlot?: React.ReactNode
/**
* 自定义 value 转换:从 field.value → Select 的 valuestring
* 常用于 number 字段:`(v) => String(v)`
*/
toSelectValue?: (value: unknown) => string
/**
* 自定义 onChange 转换:从 Select 的 string value → field.onChange 的值。
* 常用于 number 字段:`(val) => parseInt(val, 10)`
*/
fromSelectValue?: (val: string) => unknown
}
/**
* 通用选择字段组件,封装 FormField + FormItem + FormLabel + Select + SelectContent + SelectItem + FormMessage。
*
* 用于替代各表单中重复的 `<FormField render={({ field }) => <Select>...}>` 模式。
*
* @example 字符串值
* <SelectField
* control={control}
* name="subject"
* label="Subject"
* placeholder="Select subject"
* options={subjects.map(s => ({ value: s.id, label: s.name }))}
* />
*
* @example 数值字段
* <SelectField
* control={control}
* name="difficulty"
* label="Difficulty"
* toSelectValue={(v) => String(v)}
* fromSelectValue={(val) => parseInt(val, 10)}
* options={[{ value: "1", label: "Easy" }, ...]}
* />
*/
export function SelectField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
control,
name,
label,
placeholder,
description,
options,
disabled,
itemClassName,
labelSlot,
toSelectValue,
fromSelectValue,
}: SelectFieldProps<TFieldValues, TName>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={itemClassName}>
<div className="flex items-center justify-between gap-2">
<FormLabel>{label}</FormLabel>
{labelSlot}
</div>
<Select
value={toSelectValue ? toSelectValue(field.value) : (field.value as string)}
onValueChange={fromSelectValue ? (val) => field.onChange(fromSelectValue(val)) : field.onChange}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,117 @@
"use client"
import * as React from "react"
import {
type Control,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
export interface TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
/** react-hook-form control 实例 */
control: Control<TFieldValues>
/** 字段路径 */
name: TName
/** 标签文本 */
label: string
/** 占位符 */
placeholder?: string
/** 描述文本(显示在标签下) */
description?: React.ReactNode
/** Input 类型,默认 "text" */
type?: "text" | "number" | "password" | "email" | "datetime-local" | "tel" | "url"
/** 是否禁用 */
disabled?: boolean
/** FormItem 的 className用于布局如 "col-span-2" */
itemClassName?: string
/** Input 的额外 className */
inputClassName?: string
/** 透传到 Input 的其他 props如 min/max/step */
inputProps?: Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"name" | "value" | "onChange" | "onBlur" | "disabled" | "type" | "placeholder" | "className"
>
/**
* 自定义 value 转换:从 field.value → input value。
* 常用于 number 字段处理 null/undefined → ""。
*/
toInputValue?: (value: unknown) => string | number | readonly string[]
/**
* 自定义 onChange 转换:从 input event → field.onChange 的值。
* 常用于 number 字段处理空字符串 → null。
*/
fromInputValue?: (e: React.ChangeEvent<HTMLInputElement>) => unknown
}
/**
* 通用文本字段组件,封装 FormField + FormItem + FormLabel + FormControl + Input + FormMessage。
*
* 用于替代各表单中重复的 `<FormField render={({ field }) => <Input {...field} />}>` 模式。
*
* @example
* <TextField control={control} name="title" label="Title" placeholder="..." />
*
* @example 数值字段
* <TextField
* control={control}
* name="totalScore"
* label="Total Score"
* type="number"
* />
*/
export function TextField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
control,
name,
label,
placeholder,
description,
type = "text",
disabled,
itemClassName,
inputClassName,
inputProps,
toInputValue,
fromInputValue,
}: TextFieldProps<TFieldValues, TName>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={itemClassName}>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type={type}
placeholder={placeholder}
disabled={disabled}
className={inputClassName}
{...inputProps}
{...field}
value={toInputValue ? toInputValue(field.value) : (field.value ?? "")}
onChange={fromInputValue ? fromInputValue : field.onChange}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import {
type Control,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Textarea } from "@/shared/components/ui/textarea"
export interface TextareaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
/** react-hook-form control 实例 */
control: Control<TFieldValues>
/** 字段路径 */
name: TName
/** 标签文本 */
label: string
/** 占位符 */
placeholder?: string
/** 描述文本 */
description?: React.ReactNode
/** 是否禁用 */
disabled?: boolean
/** FormItem 的 className */
itemClassName?: string
/** Textarea 的额外 className如 "min-h-[200px]" */
textareaClassName?: string
/** 透传到 Textarea 的其他 props如 rows、maxLength */
textareaProps?: Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"name" | "value" | "onChange" | "onBlur" | "disabled" | "placeholder" | "className"
>
}
/**
* 通用多行文本字段组件,封装 FormField + FormItem + FormLabel + FormControl + Textarea + FormMessage。
*
* 用于替代各表单中重复的 `<FormField render={({ field }) => <Textarea {...field} />}>` 模式。
*
* @example
* <TextareaField
* control={control}
* name="content"
* label="Content"
* placeholder="Enter text..."
* textareaClassName="min-h-[100px]"
* />
*/
export function TextareaField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
control,
name,
label,
placeholder,
description,
disabled,
itemClassName,
textareaClassName,
textareaProps,
}: TextareaFieldProps<TFieldValues, TName>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={itemClassName}>
<FormLabel>{label}</FormLabel>
<FormControl>
<Textarea
placeholder={placeholder}
disabled={disabled}
className={textareaClassName}
{...textareaProps}
{...field}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,84 @@
"use client"
import type { ReactNode } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { cn } from "@/shared/lib/utils"
/**
* 删除确认对话框:统一的 AlertDialog 删除确认结构。
*
* 覆盖以下重复模式5 个文件逐行复制):
* - students-table: 移除学生
* - announcement-detail: 删除公告
* - message-detail: 删除消息
* - grade-classes-view: 删除班级
* - course-plan-detail: 删除课程计划
*
* 结构AlertDialog > Content > Header (Title + Description) > Footer (Cancel + Action)。
*/
interface ConfirmDeleteDialogProps {
/** 是否打开 */
open: boolean
/** 打开状态变更回调 */
onOpenChange: (open: boolean) => void
/** 对话框标题(如 "Delete announcement" */
title: string
/** 描述内容(支持 ReactNode便于嵌入名称等动态内容 */
description: ReactNode
/** 确认回调 */
onConfirm: () => void
/** 是否正在处理中(禁用按钮) */
isWorking?: boolean
/** 确认按钮文案(默认 "Delete" */
confirmText?: string
/** 取消按钮文案(默认 "Cancel" */
cancelText?: string
/** 是否为危险操作样式(默认 true确认按钮使用 destructive 配色) */
destructive?: boolean
}
export function ConfirmDeleteDialog({
open,
onOpenChange,
title,
description,
onConfirm,
isWorking = false,
confirmText = "Delete",
cancelText = "Cancel",
destructive = true,
}: ConfirmDeleteDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{cancelText}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isWorking}
className={cn(
destructive &&
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
)}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,37 @@
import {
TableCell,
TableRow,
} from "@/shared/components/ui/table"
/**
* 表格空行:统一的"无数据"占位行。
*
* 覆盖以下重复模式3 个审计表格逐行复制):
* - audit-log-table.tsx: colSpan=7, "No audit logs found."
* - login-log-table.tsx: colSpan=6, "No login logs found."
* - data-change-log-table.tsx: colSpan=7, "No data change logs found."
*
* 结构TableRow > TableCell(colSpan, h-24, text-center, text-muted-foreground)。
*/
interface EmptyTableRowProps {
/** 跨列数 */
colSpan: number
/** 空状态文案(默认 "No data found." */
message?: string
}
export function EmptyTableRow({
colSpan,
message = "No data found.",
}: EmptyTableRowProps) {
return (
<TableRow>
<TableCell
colSpan={colSpan}
className="h-24 text-center text-muted-foreground"
>
{message}
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,138 @@
import Link from "next/link"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
/**
* 服务端列表分页组件(基于 URL searchParams 的 Link 导航)。
*
* 与 `pagination.tsx` 的区别:
* - `pagination.tsx` 通过 `onPageChange` 回调导航,适用于客户端分页(如 audit 日志表)。
* - 本组件通过 `<Link>` 渲染分页按钮适用于服务端渲染的列表页URL 驱动分页)。
*
* 注意:本文件不使用 "use client" 指令,因为:
* 1. `ListPagination` 组件仅使用 `Link`、`Button` 和图标,无需客户端交互
* 2. `computePagination` 和 `paginate` 是纯工具函数,需要被服务端组件直接调用
*
* 使用方式:
* ```tsx
* <ListPagination
* page={page}
* totalPages={totalPages}
* total={total}
* pageSize={pageSize}
* basePath="/teacher/homework/assignments"
* searchParams={sp}
* />
* ```
*/
interface ListPaginationProps {
/** 当前页码(从 1 开始) */
page: number
/** 每页条数 */
pageSize: number
/** 总条数 */
total: number
/** 总页数 */
totalPages: number
/** 基础路径,如 "/teacher/homework/assignments" */
basePath: string
/** 当前搜索参数(会保留除 page 外的所有参数) */
searchParams: Record<string, string | string[] | undefined>
/** 条目标签(默认 "条" */
itemLabel?: string
}
export function ListPagination({
page,
pageSize,
total,
totalPages,
basePath,
searchParams,
itemLabel = "条",
}: ListPaginationProps) {
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const buildHref = (targetPage: number): string => {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(searchParams)) {
if (key === "page") continue
if (typeof value === "string") {
params.set(key, value)
} else if (Array.isArray(value)) {
for (const v of value) params.append(key, v)
}
}
if (targetPage > 1) {
params.set("page", String(targetPage))
}
const qs = params.toString()
return qs ? `${basePath}?${qs}` : basePath
}
const hasPrev = page > 1
const hasNext = page < totalPages
return (
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
<span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span>{" "}
<span className="font-medium">{total}</span> {itemLabel}
</>
) : (
`暂无${itemLabel}`
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
{page} / {Math.max(totalPages, 1)}
</span>
{hasPrev ? (
<Button asChild variant="outline" className="h-8 w-8 p-0" aria-label="上一页">
<Link href={buildHref(page - 1)}>
<ChevronLeft className="h-4 w-4" />
</Link>
</Button>
) : (
<Button variant="outline" className="h-8 w-8 p-0" disabled aria-label="上一页">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{hasNext ? (
<Button asChild variant="outline" className="h-8 w-8 p-0" aria-label="下一页">
<Link href={buildHref(page + 1)}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
) : (
<Button variant="outline" className="h-8 w-8 p-0" disabled aria-label="下一页">
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
</div>
)
}
/** 计算分页参数的工具函数(供服务端组件使用) */
export function computePagination(
searchParams: Record<string, string | string[] | undefined>,
defaultPageSize = 10
): { page: number; pageSize: number } {
const pageParam = typeof searchParams.page === "string" ? searchParams.page : undefined
const pageSizeParam = typeof searchParams.pageSize === "string" ? searchParams.pageSize : undefined
const page = Math.max(1, Number(pageParam) || 1)
const pageSize = Math.min(100, Math.max(1, Number(pageSizeParam) || defaultPageSize))
return { page, pageSize }
}
/** 对数组进行分页切片 */
export function paginate<T>(items: T[], page: number, pageSize: number): T[] {
const start = (page - 1) * pageSize
return items.slice(start, start + pageSize)
}

View File

@@ -0,0 +1,81 @@
"use client"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
/**
* 分页组件:统一的"Showing X-Y of Z items + Page X of Y + 上一页/下一页按钮"布局。
*
* 覆盖以下重复模式3 个审计表格逐行复制):
* - audit-log-table.tsx
* - login-log-table.tsx
* - data-change-log-table.tsx
*
* 结构:左侧"Showing X-Y of Z {itemLabel}",右侧"Page X of Y + 上一页/下一页按钮"。
*/
interface PaginationProps {
/** 当前页码(从 1 开始) */
page: number
/** 每页条数 */
pageSize: number
/** 总条数 */
total: number
/** 总页数 */
totalPages: number
/** 页码变更回调 */
onPageChange: (page: number) => void
/** 条目标签(默认 "logs",可改为 "records"/"items" */
itemLabel?: string
}
export function Pagination({
page,
pageSize,
total,
totalPages,
onPageChange,
itemLabel = "logs",
}: PaginationProps) {
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return (
<div className="flex items-center justify-between px-2 py-4">
<div className="text-sm text-muted-foreground">
{total > 0 ? (
<>
Showing <span className="font-medium">{start}</span>-
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> {itemLabel}
</>
) : (
`No ${itemLabel}`
)}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">
Page {page} of {Math.max(totalPages, 1)}
</span>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<span className="sr-only">Previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="sr-only">Next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { cn } from "@/shared/lib/utils"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
/**
* 状态到 Badge variant 的映射表类型。
* 业务模块可在自身 types.ts 中定义具体映射,并传入本组件。
*/
export type StatusVariantMap<T extends string> = Record<T, BadgeProps["variant"]>
/**
* 状态到展示文本的映射表类型(可选)。
* 未提供时,直接展示 status 原值。
*/
export type StatusLabelMap<T extends string> = Partial<Record<T, string>>
/**
* 状态到附加 className 的映射表类型(可选)。
* 用于在 variant 之外追加自定义颜色(如 `bg-green-600 hover:bg-green-700`)。
*/
export type StatusClassNameMap<T extends string> = Partial<Record<T, string>>
export interface StatusBadgeProps<T extends string> {
/** 当前状态值 */
status: T
/** 状态 → Badge variant 映射(必填) */
variantMap: StatusVariantMap<T>
/** 状态 → 展示文本映射(可选,缺省展示 status 原值) */
labelMap?: StatusLabelMap<T>
/** 状态 → 附加 className 映射(可选) */
classNameMap?: StatusClassNameMap<T>
/** 透传到 Badge 的根 className */
className?: string
/** 是否将展示文本首字母大写(默认 true */
capitalize?: boolean
}
/**
* 通用状态徽章组件。
*
* 用于替代各模块中重复的 `switch (status) { case ...: return <Badge variant="..."> }` 模式。
* 业务模块只需在自身 types.ts 中维护 `STATUS_VARIANT` / `STATUS_LABEL` 常量,
* 传入本组件即可,避免颜色映射不一致与代码复制粘贴。
*
* @example
* const STATUS_VARIANT = { draft: "secondary", published: "default", archived: "outline" } as const
* <StatusBadge status="published" variantMap={STATUS_VARIANT} />
*/
export function StatusBadge<T extends string>({
status,
variantMap,
labelMap,
classNameMap,
className,
capitalize = true,
}: StatusBadgeProps<T>) {
const variant = variantMap[status] ?? "secondary"
const label = labelMap?.[status] ?? status
const statusClassName = classNameMap?.[status]
return (
<Badge
variant={variant}
className={cn(capitalize && "capitalize", className, statusClassName)}
>
{label}
</Badge>
)
}