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:
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
139
src/shared/components/form-fields/select-field.tsx
Normal file
139
src/shared/components/form-fields/select-field.tsx
Normal 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 的 value(string)。
|
||||
* 常用于 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
117
src/shared/components/form-fields/text-field.tsx
Normal file
117
src/shared/components/form-fields/text-field.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
97
src/shared/components/form-fields/textarea-field.tsx
Normal file
97
src/shared/components/form-fields/textarea-field.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
84
src/shared/components/ui/confirm-delete-dialog.tsx
Normal file
84
src/shared/components/ui/confirm-delete-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/shared/components/ui/empty-table-row.tsx
Normal file
37
src/shared/components/ui/empty-table-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/shared/components/ui/list-pagination.tsx
Normal file
138
src/shared/components/ui/list-pagination.tsx
Normal 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)
|
||||
}
|
||||
81
src/shared/components/ui/pagination.tsx
Normal file
81
src/shared/components/ui/pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
src/shared/components/ui/status-badge.tsx
Normal file
71
src/shared/components/ui/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user