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>
|
||||
)
|
||||
}
|
||||
74
src/shared/hooks/use-action-mutation.ts
Normal file
74
src/shared/hooks/use-action-mutation.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export interface UseActionMutationOptions<T> {
|
||||
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
|
||||
successMessage?: string | false
|
||||
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
|
||||
errorMessage?: string | false
|
||||
/** 成功回调 */
|
||||
onSuccess?: (data: T | undefined) => void
|
||||
/** 失败回调(result.success === false 或抛出异常时) */
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export interface UseActionMutationResult<T> {
|
||||
/** 是否正在执行(try/catch/finally 期间为 true) */
|
||||
isWorking: boolean
|
||||
/** 执行 mutation。返回 ActionState 以便调用方进一步处理。 */
|
||||
mutate: (action: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Server Action mutation Hook。
|
||||
*
|
||||
* 用于替代各组件中重复的 `setIsWorking(true) + try/catch/finally + toast` 模式。
|
||||
*
|
||||
* @example
|
||||
* const { mutate, isWorking } = useActionMutation({
|
||||
* onSuccess: () => { setOpen(false); router.refresh() }
|
||||
* })
|
||||
*
|
||||
* const handleDelete = () => mutate(() => deleteSchoolAction(id))
|
||||
*/
|
||||
export function useActionMutation<T = unknown>(
|
||||
options: UseActionMutationOptions<T> = {}
|
||||
): UseActionMutationResult<T> {
|
||||
const { successMessage, errorMessage, onSuccess, onError } = options
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const mutate = useCallback(
|
||||
async (action: () => Promise<ActionState<T>>): Promise<ActionState<T> | undefined> => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const result = await action()
|
||||
if (result.success) {
|
||||
if (successMessage !== false) {
|
||||
toast.success(successMessage ?? result.message ?? "Operation succeeded")
|
||||
}
|
||||
onSuccess?.(result.data)
|
||||
} else {
|
||||
if (errorMessage !== false) {
|
||||
toast.error(errorMessage ?? result.message ?? "Operation failed")
|
||||
}
|
||||
onError?.(new Error(result.message ?? "Action returned failure"))
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (errorMessage !== false) {
|
||||
toast.error(errorMessage ?? "Operation failed")
|
||||
}
|
||||
onError?.(error)
|
||||
return undefined
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
},
|
||||
[successMessage, errorMessage, onSuccess, onError]
|
||||
)
|
||||
|
||||
return { isWorking, mutate }
|
||||
}
|
||||
84
src/shared/hooks/use-action-query.ts
Normal file
84
src/shared/hooks/use-action-query.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export interface UseActionQueryOptions {
|
||||
/** 依赖数组,默认 []。当依赖变化时重新获取。 */
|
||||
deps?: ReadonlyArray<unknown>
|
||||
/** 是否启用查询,默认 true。为 false 时不发起请求。 */
|
||||
enabled?: boolean
|
||||
/** 失败时显示的 toast 文案。传 false 则不显示。默认 "Failed to load data"。 */
|
||||
errorMessage?: string | false
|
||||
}
|
||||
|
||||
export interface UseActionQueryResult<T> {
|
||||
data: T | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Server Action 查询 Hook。
|
||||
*
|
||||
* 用于替代各组件中重复的 `useEffect + useState(loading) + Action().then().catch().finally()` 模式。
|
||||
* 内置竞态防护(通过 ref 跟踪最新请求)。
|
||||
*
|
||||
* @example
|
||||
* const { data, loading } = useActionQuery(
|
||||
* () => getKnowledgePointOptionsAction(),
|
||||
* { deps: [open], enabled: open }
|
||||
* )
|
||||
*/
|
||||
export function useActionQuery<T>(
|
||||
action: () => Promise<ActionState<T>>,
|
||||
options: UseActionQueryOptions = {}
|
||||
): UseActionQueryResult<T> {
|
||||
const { deps = [], enabled = true, errorMessage = "Failed to load data" } = options
|
||||
const [data, setData] = useState<T | undefined>(undefined)
|
||||
const [loading, setLoading] = useState<boolean>(enabled)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [refetchCount, setRefetchCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
action()
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
} else {
|
||||
setError(new Error(result.message ?? "Action failed"))
|
||||
if (errorMessage !== false) {
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err : new Error(String(err)))
|
||||
if (errorMessage !== false) {
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, refetchCount])
|
||||
|
||||
const refetch = () => setRefetchCount((c) => c + 1)
|
||||
|
||||
return { data, loading, error, refetch }
|
||||
}
|
||||
@@ -46,7 +46,13 @@
|
||||
"publishedAndOngoing": "Published and ongoing",
|
||||
"submissionRate": "Submission Rate",
|
||||
"overallCompletionRate": "Overall completion rate",
|
||||
"acrossRecentAssignments": "Across recent assignments"
|
||||
"acrossRecentAssignments": "Across recent assignments",
|
||||
"textbooks": "Textbooks",
|
||||
"chapters": "Chapters",
|
||||
"questions": "Questions",
|
||||
"exams": "Exams",
|
||||
"totalAssignments": "Total assignments",
|
||||
"totalSubmissions": "Total submissions"
|
||||
},
|
||||
"quickActions": {
|
||||
"importUsers": "Import Users",
|
||||
|
||||
@@ -34,9 +34,17 @@
|
||||
"chart": {
|
||||
"radarTitle": "Knowledge Point Mastery",
|
||||
"radarDescription": "Radar chart of mastery level (student vs class average)",
|
||||
"radarDescriptionNonEmpty": "Radar chart of mastery level (0-100) across knowledge points.",
|
||||
"heatmapTitle": "Knowledge Point Mastery Heatmap",
|
||||
"rankingTitle": "Knowledge Point Ranking",
|
||||
"noMasteryData": "No knowledge point mastery records found."
|
||||
"noMasteryData": "No knowledge point mastery records found.",
|
||||
"noMasteryDataForStudent": "No knowledge point mastery records found for this student.",
|
||||
"radarEmptyTitle": "No mastery data",
|
||||
"radarAriaLabelEmpty": "Knowledge point mastery radar chart: no data",
|
||||
"radarAriaLabelNonEmpty": "Knowledge point mastery radar chart: {count} knowledge points{withClassAverage}",
|
||||
"withClassAverage": " (with class average comparison)",
|
||||
"studentSeries": "Student",
|
||||
"classAvgSeries": "Class Avg"
|
||||
},
|
||||
"report": {
|
||||
"generate": "Generate Diagnostic Report",
|
||||
@@ -44,6 +52,7 @@
|
||||
"generateClass": "Generate Class Diagnostic Report",
|
||||
"publish": "Publish",
|
||||
"delete": "Delete",
|
||||
"export": "Export",
|
||||
"publishTitle": "Publish Report",
|
||||
"publishConfirmation": "Are you sure you want to publish this report? It will be visible to relevant users.",
|
||||
"deleteTitle": "Delete Report",
|
||||
@@ -82,6 +91,99 @@
|
||||
"publishFailed": "Failed to publish",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"loadFailed": "Failed to load",
|
||||
"exportFailed": "Export failed",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"classDiagnostic": {
|
||||
"noClassDataTitle": "No class data",
|
||||
"heatmapDescription": "Average mastery level per knowledge point (green >=80%, yellow 60-79%, orange 40-59%, red <40%).",
|
||||
"noKnowledgePointData": "No knowledge point data available.",
|
||||
"heatmapAriaLabel": "Knowledge point mastery heatmap, {count} knowledge points. Color indicates mastery level: green >=80% excellent, yellow 60-79% good, orange 40-59% needs improvement, red <40% weak",
|
||||
"masteryLevelExcellent": "Excellent",
|
||||
"masteryLevelGood": "Good",
|
||||
"masteryLevelNeedsImprovement": "Needs Improvement",
|
||||
"masteryLevelWeak": "Weak",
|
||||
"legendLabel": "Color Legend",
|
||||
"noRankingData": "No data.",
|
||||
"knowledgePointColumn": "Knowledge Point",
|
||||
"avgMasteryColumn": "Avg Mastery",
|
||||
"masteredColumn": "Mastered (≥80%)",
|
||||
"notMasteredColumn": "Not Mastered (<60%)",
|
||||
"studentsNeedingAttentionTitle": "Students Needing Attention (avg <60%)",
|
||||
"studentsNeedingAttentionDescription": "Students with low overall mastery.",
|
||||
"allStudentsAboveThreshold": "All students are above the attention threshold.",
|
||||
"weakPointsColumn": "Weak Points",
|
||||
"viewAction": "View",
|
||||
"viewAriaLabel": "View {studentName}'s diagnostic details",
|
||||
"filterByKpTitle": "Filter Students by Knowledge Point",
|
||||
"filterByKpDescription": "Select a knowledge point to view all students' mastery levels for targeted tutoring.",
|
||||
"kpFilterLabel": "Select knowledge point",
|
||||
"kpFilterPlaceholder": "Select a knowledge point",
|
||||
"kpFilterAll": "All knowledge points",
|
||||
"filtering": "Filtering...",
|
||||
"totalQuestionsColumn": "Total Questions",
|
||||
"correctQuestionsColumn": "Correct",
|
||||
"statusColumn": "Status",
|
||||
"needsAttention": "Needs Attention",
|
||||
"mastered": "Mastered",
|
||||
"noStudentsForKp": "No mastery data available for this knowledge point.",
|
||||
"generateDescription": "Generate a class-level diagnostic report with aggregated analysis.",
|
||||
"periodLabel": "Period (YYYY-MM)",
|
||||
"generating": "Generating...",
|
||||
"generateButton": "Generate Class Report"
|
||||
},
|
||||
"reportList": {
|
||||
"caption": "Diagnostic reports list",
|
||||
"typeColumn": "Type",
|
||||
"studentTargetColumn": "Student / Target",
|
||||
"periodColumn": "Period",
|
||||
"scoreColumn": "Score",
|
||||
"statusColumn": "Status",
|
||||
"generatedByColumn": "Generated By",
|
||||
"dateColumn": "Date",
|
||||
"actionsColumn": "Actions",
|
||||
"classReportPlaceholder": "(Class report)",
|
||||
"gradeReportPlaceholder": "(Grade report)",
|
||||
"noReportsDescription": "Generate diagnostic reports to see them here.",
|
||||
"publishConfirmation": "Once published, the report will be visible to students. Continue?",
|
||||
"publishAriaLabel": "Publish report {studentName}",
|
||||
"deleteAriaLabel": "Delete report {studentName}",
|
||||
"exportAriaLabel": "Export report {studentName}",
|
||||
"exportSuccess": "Report exported",
|
||||
"share": "Share",
|
||||
"shareAriaLabel": "Share report {studentName}",
|
||||
"shareTitle": "Share Report",
|
||||
"shareDescription": "Copy the report link to share with students or parents.",
|
||||
"shareLinkLabel": "Report Link",
|
||||
"copyLink": "Copy Link",
|
||||
"copyLinkSuccess": "Link copied",
|
||||
"copyLinkFailed": "Failed to copy link",
|
||||
"shareLinkAriaLabel": "Report share link",
|
||||
"confidenceColumn": "Confidence",
|
||||
"confidenceHigh": "High",
|
||||
"confidenceMedium": "Medium",
|
||||
"confidenceLow": "Low",
|
||||
"confidenceInsufficient": "Insufficient Data",
|
||||
"confidenceHighHint": "Sufficient data, conclusions are reliable",
|
||||
"confidenceMediumHint": "Limited data, consider with other information",
|
||||
"confidenceLowHint": "Little data, conclusions are for reference only",
|
||||
"confidenceAriaLabel": "Data confidence: {level}"
|
||||
},
|
||||
"studentDiagnostic": {
|
||||
"noDataDescription": "Unable to load student mastery data.",
|
||||
"strengthsDescription": "Knowledge points with high mastery.",
|
||||
"weaknessesDescription": "Knowledge points needing attention.",
|
||||
"diagnosticReportTitle": "Diagnostic Report",
|
||||
"overallLabel": "Overall",
|
||||
"historyDescription": "Past diagnostic reports (newest first).",
|
||||
"untitledPeriod": "Untitled period",
|
||||
"strengthsListAriaLabel": "Strengths knowledge points list",
|
||||
"weaknessesListAriaLabel": "Weaknesses knowledge points list",
|
||||
"recommendationsListAriaLabel": "Recommendations list",
|
||||
"practiceAriaLabel": "Practice knowledge point: {name}",
|
||||
"noStrengths": "No strengths identified yet.",
|
||||
"noWeaknesses": "No weaknesses identified.",
|
||||
"reportMeta": "Period: {period} · Overall: {score}%",
|
||||
"historyReportMeta": "{date} · Overall: {score}%"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
},
|
||||
"list": {
|
||||
"empty": "No grade records found.",
|
||||
"caption": "Grade records list",
|
||||
"deleteAriaLabel": "Delete {studentName}'s {subjectName} grade record",
|
||||
"editAriaLabel": "Edit {studentName}'s {subjectName} grade record",
|
||||
"bulkDelete": "Bulk Delete",
|
||||
"bulkDeleteConfirmation": "Are you sure you want to delete the {count} selected grade record(s)? This action cannot be undone.",
|
||||
"bulkDeleteSelected": "{count} record(s) selected",
|
||||
"bulkDeleteSuccess": "Deleted {count} grade record(s)",
|
||||
"bulkDeleteFailed": "Bulk delete failed",
|
||||
"selectAll": "Select all",
|
||||
"selectRow": "Select {name}'s grade record",
|
||||
"clearSelection": "Clear selection",
|
||||
"columns": {
|
||||
"student": "Student",
|
||||
"class": "Class",
|
||||
@@ -39,7 +50,8 @@
|
||||
"type": "Type",
|
||||
"semester": "Semester",
|
||||
"recordedBy": "Recorded By",
|
||||
"date": "Date"
|
||||
"date": "Date",
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -55,7 +67,9 @@
|
||||
"fullScore": "Full Score",
|
||||
"remark": "Remark (optional)",
|
||||
"remarkPlaceholder": "Notes about this grade...",
|
||||
"selectPrompt": "Please select class, subject and student"
|
||||
"selectPrompt": "Please select class, subject and student",
|
||||
"student": "Student",
|
||||
"titleLabel": "Title"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Grade Record",
|
||||
@@ -64,11 +78,63 @@
|
||||
"cancel": "Cancel",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Grade Record",
|
||||
"confirm": "Save Changes",
|
||||
"saving": "Saving...",
|
||||
"cancel": "Cancel",
|
||||
"score": "Score",
|
||||
"fullScore": "Full Score",
|
||||
"titleLabel": "Title",
|
||||
"remark": "Remark (optional)",
|
||||
"remarkPlaceholder": "Notes about this grade...",
|
||||
"success": "Grade record updated",
|
||||
"failed": "Update failed"
|
||||
},
|
||||
"export": {
|
||||
"detail": "Export Grade Details",
|
||||
"classReport": "Export Class Grade Report",
|
||||
"success": "Export succeeded",
|
||||
"failed": "Export failed"
|
||||
"failed": "Export failed",
|
||||
"exporting": "Exporting...",
|
||||
"selectClassFirst": "Please select a class first",
|
||||
"detailItem": "Grade Details",
|
||||
"classReportItem": "Class Grade Report",
|
||||
"defaultLabel": "Export",
|
||||
"sheets": {
|
||||
"detail": "Grade Details",
|
||||
"summary": "Statistics Summary",
|
||||
"classReport": "{className}_Grade Report"
|
||||
},
|
||||
"columns": {
|
||||
"studentName": "Student Name",
|
||||
"class": "Class",
|
||||
"subject": "Subject",
|
||||
"title": "Title",
|
||||
"score": "Score",
|
||||
"fullScore": "Full Score",
|
||||
"type": "Type",
|
||||
"semester": "Semester",
|
||||
"recorder": "Recorder",
|
||||
"remark": "Remark",
|
||||
"date": "Date",
|
||||
"metric": "Metric",
|
||||
"value": "Value",
|
||||
"total": "Total",
|
||||
"average": "Average",
|
||||
"rank": "Rank"
|
||||
},
|
||||
"metrics": {
|
||||
"average": "Average",
|
||||
"median": "Median",
|
||||
"max": "Max",
|
||||
"min": "Min",
|
||||
"stdDev": "Std Dev",
|
||||
"passRate": "Pass Rate (%)",
|
||||
"excellentRate": "Excellent Rate (%)",
|
||||
"count": "Count",
|
||||
"noData": "No Data"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
@@ -80,7 +146,11 @@
|
||||
"variance": "Variance",
|
||||
"passRate": "Pass Rate",
|
||||
"excellentRate": "Excellent Rate",
|
||||
"count": "Count"
|
||||
"count": "Count",
|
||||
"noData": "No data available for statistics.",
|
||||
"stdDevHint": "Standard deviation",
|
||||
"passRateHint": "Score >= 60% of full",
|
||||
"excellentRateHint": "Score >= 85% of full"
|
||||
},
|
||||
"analytics": {
|
||||
"trend": "Grade Trend",
|
||||
@@ -95,12 +165,22 @@
|
||||
"averageScore": "Average score, pass rate, excellent rate",
|
||||
"passRate": "Pass Rate",
|
||||
"excellentRate": "Excellent Rate",
|
||||
"studentCount": "Student Count"
|
||||
"studentCount": "Student Count",
|
||||
"classComparisonLabel": "Grade (for class comparison)",
|
||||
"allOption": "All",
|
||||
"semester": "Semester",
|
||||
"semesterAll": "All Semesters",
|
||||
"semester1": "Semester 1",
|
||||
"semester2": "Semester 2",
|
||||
"exam": "Exam",
|
||||
"examAll": "All Exams",
|
||||
"noExams": "No linked exams"
|
||||
},
|
||||
"batch": {
|
||||
"title": "Batch Grade Entry",
|
||||
"saving": "Saving...",
|
||||
"restored": "Restored unsaved grade draft",
|
||||
"restoredFromServer": "Restored grade draft from server (cross-device sync)",
|
||||
"invalidScores": "Invalid scores found",
|
||||
"fullScoreRequired": "Full score is required",
|
||||
"saved": "Saved",
|
||||
@@ -109,7 +189,45 @@
|
||||
"fullScore": "Full Score",
|
||||
"type": "Type",
|
||||
"saveAll": "Save All",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"saveAllGrades": "Save All Grades",
|
||||
"savingGrades": "Saving grades...",
|
||||
"selectClassAndSubject": "Please select class and subject",
|
||||
"invalidScoresError": "Invalid scores found (exceeding full score or wrong format). Please check and retry.",
|
||||
"enterAtLeastOne": "Please enter at least one score",
|
||||
"noStudentsInClass": "No students in this class.",
|
||||
"entered": "Entered",
|
||||
"average": "Avg",
|
||||
"max": "Max",
|
||||
"min": "Min",
|
||||
"searchStudent": "Search student...",
|
||||
"examQuizTitle": "Exam / Quiz Title",
|
||||
"fullScoreHint": "Full score is {max}. Press Enter to move to the next student. Drafts are auto-saved every 30 seconds and valid for 2 hours.",
|
||||
"confirmSwitchClass": "There are unsaved grade records for the current class. Are you sure you want to switch?",
|
||||
"invalidScoresBadge": "Invalid scores found",
|
||||
"emailColumn": "Email",
|
||||
"pasteApplied": "Pasted {count} scores from clipboard",
|
||||
"pasteNoMatch": "Clipboard content could not be parsed as a score column",
|
||||
"pasteHint": "Tip: copy a column of scores from Excel and paste here",
|
||||
"undo": "Undo",
|
||||
"undoNoRecord": "No recent batch entry to undo",
|
||||
"undoExpired": "Undo expired (more than 5 minutes)",
|
||||
"undoFailed": "Undo failed, please try again",
|
||||
"downloadTemplate": "Download Template",
|
||||
"templateAriaLabel": "Download grade entry template",
|
||||
"templateStudentName": "Student Name",
|
||||
"templateScore": "Score",
|
||||
"templateRemark": "Remark",
|
||||
"templateFilename": "grade-entry-template.csv",
|
||||
"guide": {
|
||||
"title": "Grade Entry Guide",
|
||||
"step1": "Select the class and subject for grade entry, and fill in the exam or quiz title.",
|
||||
"step2": "Enter scores directly in the score column, or copy a column of scores from Excel and paste here.",
|
||||
"step3": "Drafts are auto-saved every 30 seconds (2 hours locally, 24 hours on server for cross-device sync).",
|
||||
"step4": "After saving, you can undo the entry within 5 minutes by clicking the undo button.",
|
||||
"dismiss": "Got it",
|
||||
"dismissAriaLabel": "Close entry guide"
|
||||
}
|
||||
},
|
||||
"trend": {
|
||||
"title": "Grade Trend",
|
||||
@@ -118,12 +236,20 @@
|
||||
"date": "Date"
|
||||
},
|
||||
"summary": {
|
||||
"caption": "Student Grade Summary",
|
||||
"title": "Grade Summary",
|
||||
"averageScore": "Average Score",
|
||||
"classRank": "Class Rank",
|
||||
"rankValue": "#{rank}",
|
||||
"totalRecords": "Total Records",
|
||||
"highestScore": "Highest Score",
|
||||
"lowestScore": "Lowest Score"
|
||||
"lowestScore": "Lowest Score",
|
||||
"student": "Student",
|
||||
"gradeHistory": "Grade History",
|
||||
"noGradesTitle": "No grades yet",
|
||||
"noGradesDescription": "There are no grade records for this student.",
|
||||
"noDataTitle": "No data",
|
||||
"noDataDescription": "Student grade summary is not available."
|
||||
},
|
||||
"empty": {
|
||||
"noRecords": "No grade records found.",
|
||||
@@ -139,5 +265,90 @@
|
||||
"failedToCreate": "Failed to create",
|
||||
"failedToDelete": "Failed to delete",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"widget": {
|
||||
"error": "Widget Error",
|
||||
"retry": "Retry",
|
||||
"loading": "Loading...",
|
||||
"block": "Widget",
|
||||
"loadFailed": "{title} failed to load",
|
||||
"defaultFallback": "Please retry or refresh the page",
|
||||
"retryAriaLabel": "Retry loading {title}",
|
||||
"loadingAriaLabel": "{title} is loading"
|
||||
},
|
||||
"chart": {
|
||||
"scorePercent": "Score (%)",
|
||||
"students": "Students",
|
||||
"averagePercent": "Average (%)",
|
||||
"passRatePercent": "Pass Rate (%)",
|
||||
"excellentPercent": "Excellent (%)",
|
||||
"average": "Average",
|
||||
"passRate": "Pass Rate",
|
||||
"classAverage": "Class Average"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "Score Distribution",
|
||||
"descriptionEmpty": "Number of students in each score range (normalized to 0-100).",
|
||||
"descriptionNonEmpty": "{count} grade record(s) across score ranges.",
|
||||
"emptyTitle": "No distribution data",
|
||||
"emptyDescription": "Select a class and subject to view score distribution.",
|
||||
"ariaLabelEmpty": "Score distribution bar chart: no data",
|
||||
"ariaLabelNonEmpty": "Score distribution bar chart: {count} grade records across 5 score ranges",
|
||||
"tooltipStudents": "{count} student(s)",
|
||||
"tooltipOfTotal": "{percentage}% of total"
|
||||
},
|
||||
"subjectComparison": {
|
||||
"descriptionEmpty": "Compare performance across subjects for the selected class.",
|
||||
"descriptionNonEmpty": "Average score and pass rate per subject (normalized to 0-100).",
|
||||
"emptyTitle": "No comparison data",
|
||||
"emptyDescription": "Select a class to compare subject performance.",
|
||||
"ariaLabelEmpty": "Subject comparison radar chart: no data",
|
||||
"ariaLabelNonEmpty": "Subject comparison radar chart: comparing average and pass rate across {count} subjects"
|
||||
},
|
||||
"classComparison": {
|
||||
"descriptionEmpty": "Compare average, pass rate, and excellent rate across classes.",
|
||||
"descriptionNonEmpty": "Average score, pass rate (>=60%), and excellent rate (>=85%) per class.",
|
||||
"emptyTitle": "No comparison data",
|
||||
"emptyDescription": "Select a grade and subject to compare classes.",
|
||||
"ariaLabelEmpty": "Class comparison bar chart: no data",
|
||||
"ariaLabelNonEmpty": "Class comparison bar chart: comparing average, pass rate, and excellent rate across {count} classes",
|
||||
"significanceTitle": "Significance Analysis",
|
||||
"significanceRange": "Max difference between classes: {range} points",
|
||||
"significanceHigh": "Significant difference",
|
||||
"significanceMedium": "Possible difference",
|
||||
"significanceLow": "No significant difference",
|
||||
"significanceHighHint": "The range is large and each class has sufficient sample size (>=30), so the performance gap is statistically meaningful.",
|
||||
"significanceMediumHint": "Some difference exists, but it may be affected by sample size or random fluctuation. Further analysis is recommended.",
|
||||
"significanceLowHint": "The range is small and class performance is close; the difference is not statistically meaningful.",
|
||||
"significanceAriaLabel": "Significance analysis: {level}",
|
||||
"significanceDetails": "View detailed analysis",
|
||||
"significanceTopClass": "Top class: {name} ({score} points)",
|
||||
"significanceBottomClass": "Bottom class: {name} ({score} points)"
|
||||
},
|
||||
"trendChart": {
|
||||
"descriptionEmpty": "Score progression over time (normalized to 0-100).",
|
||||
"descriptionNonEmpty": "{label} · avg {average}%",
|
||||
"emptyTitle": "No trend data",
|
||||
"emptyDescription": "Select a class and subject to view the grade trend.",
|
||||
"ariaLabelEmpty": "Grade trend chart: no data",
|
||||
"ariaLabelNonEmpty": "Grade trend chart: {label}, average {average}%"
|
||||
},
|
||||
"trendCard": {
|
||||
"emptyDescription": "Your grade trend will appear here once records are added.",
|
||||
"ariaLabelEmpty": "Grade trend chart: no data",
|
||||
"ariaLabelNonEmpty": "Grade trend chart: {count} grade records",
|
||||
"rangeAriaLabel": "Select date range"
|
||||
},
|
||||
"rankingTrend": {
|
||||
"ariaLabelEmpty": "Ranking trend chart: no data",
|
||||
"ariaLabelNonEmpty": "Ranking trend chart: {count} ranking records"
|
||||
},
|
||||
"classReport": {
|
||||
"noDataTitle": "No data",
|
||||
"noDataDescription": "No grade records found for this class.",
|
||||
"classRanking": "Class Ranking",
|
||||
"rankColumn": "Rank",
|
||||
"recordsColumn": "Records",
|
||||
"studentCountInfo": "{studentCount} students · {recordCount} grade records"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,34 @@
|
||||
"unknownBlockType": "Unknown node type",
|
||||
"nodeSummaryEmpty": "Empty",
|
||||
"questionCount": "{count} questions",
|
||||
"charCount": "{count} chars"
|
||||
"charCount": "{count} chars",
|
||||
"itemCount": "{count} items",
|
||||
"pointCount": "{count} points",
|
||||
"assignmentCount": "{count} assignments",
|
||||
"durationMin": "{count} min",
|
||||
"textbookContent": "Textbook Content",
|
||||
"textbookContentMissing": "Content node missing",
|
||||
"textbookContentEmpty": "(No content loaded)",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"rangeAnchorTitle": "Link selection to a node",
|
||||
"pointAnchorTitle": "Insert placeholder {number} here",
|
||||
"anchorToSelectedNode": "Link to selected node",
|
||||
"anchorToNewNode": "Link to a new node"
|
||||
},
|
||||
"picker": {
|
||||
"textbookLabel": "Select Textbook",
|
||||
"chapterLabel": "Select Chapter",
|
||||
"selectTextbook": "Select a textbook...",
|
||||
"selectChapter": "Select a chapter...",
|
||||
"selectTextbookFirst": "Please select a textbook first",
|
||||
"loadingTextbooks": "Loading textbooks...",
|
||||
"loadingChapters": "Loading chapters...",
|
||||
"noTextbooks": "No textbooks available. Please create one in the textbook module first",
|
||||
"noChapters": "No chapters in this textbook",
|
||||
"selectedChapter": "Selected: {chapter}",
|
||||
"skeletonHint": "A lesson skeleton centered on the textbook content will be generated (10 teaching nodes)",
|
||||
"errorTextbookChapterRequired": "Please select a textbook and chapter"
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "Search title...",
|
||||
@@ -126,13 +153,15 @@
|
||||
"linked": "Linked to {count} knowledge points",
|
||||
"annotate": "Annotate Knowledge Points",
|
||||
"select": "Select Knowledge Points",
|
||||
"selected": "{count} selected"
|
||||
"selected": "{count} selected",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"questionBank": {
|
||||
"title": "Select Questions from Bank",
|
||||
"add": "Add",
|
||||
"insert": "Insert",
|
||||
"selected": "{count} questions selected",
|
||||
"loading": "Loading...",
|
||||
"source": {
|
||||
"bank": "Bank",
|
||||
"inline": "New"
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
"profile": {
|
||||
"title": "Profile Information",
|
||||
"description": "Update your personal information.",
|
||||
"avatar": {
|
||||
"upload": "Upload Avatar",
|
||||
"remove": "Remove",
|
||||
"hint": "Supports JPG, PNG, WebP, GIF. Max 2MB.",
|
||||
"uploadSuccess": "Avatar updated successfully",
|
||||
"uploadFailure": "Failed to upload avatar",
|
||||
"removeSuccess": "Avatar removed",
|
||||
"removeFailure": "Failed to remove avatar",
|
||||
"invalidType": "Only JPG, PNG, WebP, and GIF formats are supported",
|
||||
"tooLarge": "File size must not exceed 2MB",
|
||||
"tooLongName": "Filename is too long, please rename it (max 255 characters)"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Full Name",
|
||||
"namePlaceholder": "Your name",
|
||||
@@ -68,7 +80,10 @@
|
||||
"save": "Save Preferences",
|
||||
"saving": "Saving...",
|
||||
"success": "Preferences updated",
|
||||
"failure": "Failed to update preferences"
|
||||
"failure": "Failed to update preferences",
|
||||
"test": "Test",
|
||||
"testSuccess": "Test notification sent",
|
||||
"testFailure": "Failed to send test notification"
|
||||
},
|
||||
"appearance": {
|
||||
"theme": {
|
||||
@@ -120,6 +135,63 @@
|
||||
"tip2": "Avoid common words, names, or sequential patterns.",
|
||||
"tip3": "Change your password periodically.",
|
||||
"tip4": "Your account will be temporarily locked after multiple failed login attempts."
|
||||
},
|
||||
"center": {
|
||||
"title": "Security Center",
|
||||
"description": "Manage two-factor authentication and review login activity.",
|
||||
"twoFactor": {
|
||||
"title": "Two-Factor Authentication (2FA)",
|
||||
"description": "When enabled, login requires a one-time code from your authenticator app.",
|
||||
"enabled": "Enabled",
|
||||
"hint": "Enabling 2FA significantly improves account security and prevents unauthorized access from leaked passwords.",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enableSuccess": "Two-factor authentication enabled",
|
||||
"disableSuccess": "Two-factor authentication disabled",
|
||||
"toggleFailure": "Operation failed, please try again later",
|
||||
"setupFailure": "Failed to generate QR code, please try again later",
|
||||
"verifyFailure": "Verification failed, please try again later",
|
||||
"disableFailure": "Failed to disable 2FA, please try again later",
|
||||
"regenerateFailure": "Failed to regenerate backup codes, please try again later",
|
||||
"regenerateSuccess": "Backup codes regenerated",
|
||||
"invalidCode": "Invalid verification code, please try again",
|
||||
"scanQr": "Scan the QR code above with your authenticator app (e.g. Google Authenticator, Microsoft Authenticator)",
|
||||
"manualEntry": "Can't scan? Enter this key manually",
|
||||
"enterCode": "Enter the 6-digit code from your authenticator",
|
||||
"enterCodeDisable": "Enter a one-time code or backup code to confirm disabling 2FA",
|
||||
"enterCodeRegen": "Enter your current one-time code to regenerate backup codes",
|
||||
"verify": "Verify & Enable",
|
||||
"cancel": "Cancel",
|
||||
"done": "Done",
|
||||
"backupCodes": "Backup Codes",
|
||||
"backupHint": "Used to recover your account when you can't access your authenticator",
|
||||
"backupRemaining": "{count} backup codes remaining",
|
||||
"backupWarning": "Save these backup codes securely. Each code can only be used once. Lost codes cannot be recovered, only regenerated.",
|
||||
"copy": "Copy all",
|
||||
"copied": "Copied",
|
||||
"regenerate": "Regenerate",
|
||||
"disableTitle": "Disable Two-Factor Authentication",
|
||||
"disableDescription": "After disabling, login will no longer require a one-time code. To confirm this is you, please enter your current one-time code or a backup code.",
|
||||
"regenerateTitle": "Regenerate Backup Codes",
|
||||
"regenerateDescription": "This will invalidate all old backup codes and generate 10 new ones. Please enter your current one-time code to confirm."
|
||||
},
|
||||
"recentLogins": {
|
||||
"title": "Recent Logins",
|
||||
"showingLatest": "Latest {count} records",
|
||||
"empty": "No login records",
|
||||
"failed": "Failed",
|
||||
"current": "Current session",
|
||||
"revokeAll": "Sign out all other sessions",
|
||||
"revoking": "Processing...",
|
||||
"revokeSuccess": "Revoked {count} session(s)",
|
||||
"revokeSuccessEmpty": "No other active sessions to revoke",
|
||||
"revokeFailure": "Failed to revoke sessions, please try again later",
|
||||
"actions": {
|
||||
"signin": "Sign in",
|
||||
"signout": "Sign out",
|
||||
"signup": "Sign up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -46,7 +46,13 @@
|
||||
"publishedAndOngoing": "已发布且进行中",
|
||||
"submissionRate": "提交率",
|
||||
"overallCompletionRate": "总体完成率",
|
||||
"acrossRecentAssignments": "近期作业平均"
|
||||
"acrossRecentAssignments": "近期作业平均",
|
||||
"textbooks": "教材数",
|
||||
"chapters": "章节数",
|
||||
"questions": "题目数",
|
||||
"exams": "考试数",
|
||||
"totalAssignments": "作业总数",
|
||||
"totalSubmissions": "提交总数"
|
||||
},
|
||||
"quickActions": {
|
||||
"importUsers": "批量导入用户",
|
||||
|
||||
@@ -34,9 +34,17 @@
|
||||
"chart": {
|
||||
"radarTitle": "知识点掌握度",
|
||||
"radarDescription": "掌握度雷达图(学生 vs 班级平均)",
|
||||
"radarDescriptionNonEmpty": "各知识点掌握度(0-100)雷达图。",
|
||||
"heatmapTitle": "知识点掌握度热力图",
|
||||
"rankingTitle": "知识点排名",
|
||||
"noMasteryData": "暂无知识点掌握度记录"
|
||||
"noMasteryData": "暂无知识点掌握度记录",
|
||||
"noMasteryDataForStudent": "暂无该学生的知识点掌握度记录。",
|
||||
"radarEmptyTitle": "暂无掌握度数据",
|
||||
"radarAriaLabelEmpty": "知识点掌握度雷达图:暂无数据",
|
||||
"radarAriaLabelNonEmpty": "知识点掌握度雷达图:共 {count} 个知识点的掌握度{withClassAverage}",
|
||||
"withClassAverage": "(含班级平均对比)",
|
||||
"studentSeries": "学生",
|
||||
"classAvgSeries": "班级平均"
|
||||
},
|
||||
"report": {
|
||||
"generate": "生成诊断报告",
|
||||
@@ -44,6 +52,7 @@
|
||||
"generateClass": "生成班级诊断报告",
|
||||
"publish": "发布",
|
||||
"delete": "删除",
|
||||
"export": "导出",
|
||||
"publishTitle": "发布报告",
|
||||
"publishConfirmation": "确定要发布此报告吗?发布后将对相关人员可见。",
|
||||
"deleteTitle": "删除报告",
|
||||
@@ -82,6 +91,99 @@
|
||||
"publishFailed": "发布失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"loadFailed": "加载失败",
|
||||
"exportFailed": "导出失败",
|
||||
"retry": "重试"
|
||||
},
|
||||
"classDiagnostic": {
|
||||
"noClassDataTitle": "暂无班级数据",
|
||||
"heatmapDescription": "各知识点平均掌握度(绿色≥80%优秀,黄色60-79%良好,橙色40-59%待提升,红色<40%薄弱)。",
|
||||
"noKnowledgePointData": "暂无知识点数据。",
|
||||
"heatmapAriaLabel": "知识点掌握度热力图,共 {count} 个知识点,颜色表示掌握度等级:绿色≥80%优秀,黄色60-79%良好,橙色40-59%待提升,红色<40%薄弱",
|
||||
"masteryLevelExcellent": "优秀",
|
||||
"masteryLevelGood": "良好",
|
||||
"masteryLevelNeedsImprovement": "待提升",
|
||||
"masteryLevelWeak": "薄弱",
|
||||
"legendLabel": "颜色图例",
|
||||
"noRankingData": "暂无数据。",
|
||||
"knowledgePointColumn": "知识点",
|
||||
"avgMasteryColumn": "平均掌握度",
|
||||
"masteredColumn": "已掌握(≥80%)",
|
||||
"notMasteredColumn": "未掌握(<60%)",
|
||||
"studentsNeedingAttentionTitle": "需重点关注的学生(平均<60%)",
|
||||
"studentsNeedingAttentionDescription": "掌握度较低的学生。",
|
||||
"allStudentsAboveThreshold": "所有学生的掌握度均高于关注阈值。",
|
||||
"weakPointsColumn": "薄弱点",
|
||||
"viewAction": "查看",
|
||||
"viewAriaLabel": "查看 {studentName} 的诊断详情",
|
||||
"filterByKpTitle": "按知识点筛选学生",
|
||||
"filterByKpDescription": "选择知识点查看班级所有学生在此知识点上的掌握度,便于针对性辅导。",
|
||||
"kpFilterLabel": "选择知识点",
|
||||
"kpFilterPlaceholder": "请选择知识点",
|
||||
"kpFilterAll": "全部知识点",
|
||||
"filtering": "筛选中...",
|
||||
"totalQuestionsColumn": "总题数",
|
||||
"correctQuestionsColumn": "正确数",
|
||||
"statusColumn": "状态",
|
||||
"needsAttention": "需关注",
|
||||
"mastered": "已掌握",
|
||||
"noStudentsForKp": "该知识点暂无学生掌握度数据。",
|
||||
"generateDescription": "生成包含汇总分析的班级诊断报告。",
|
||||
"periodLabel": "周期(YYYY-MM)",
|
||||
"generating": "生成中...",
|
||||
"generateButton": "生成班级报告"
|
||||
},
|
||||
"reportList": {
|
||||
"caption": "学情诊断报告列表",
|
||||
"typeColumn": "类型",
|
||||
"studentTargetColumn": "学生 / 目标",
|
||||
"periodColumn": "周期",
|
||||
"scoreColumn": "得分",
|
||||
"statusColumn": "状态",
|
||||
"generatedByColumn": "生成者",
|
||||
"dateColumn": "日期",
|
||||
"actionsColumn": "操作",
|
||||
"classReportPlaceholder": "(班级报告)",
|
||||
"gradeReportPlaceholder": "(年级报告)",
|
||||
"noReportsDescription": "生成诊断报告后将显示在此处。",
|
||||
"publishConfirmation": "发布后报告将对相关学生可见。确定要发布吗?",
|
||||
"publishAriaLabel": "发布报告 {studentName}",
|
||||
"deleteAriaLabel": "删除报告 {studentName}",
|
||||
"exportAriaLabel": "导出报告 {studentName}",
|
||||
"exportSuccess": "报告已导出",
|
||||
"share": "分享",
|
||||
"shareAriaLabel": "分享报告 {studentName}",
|
||||
"shareTitle": "分享报告",
|
||||
"shareDescription": "复制报告链接分享给学生或家长。",
|
||||
"shareLinkLabel": "报告链接",
|
||||
"copyLink": "复制链接",
|
||||
"copyLinkSuccess": "链接已复制",
|
||||
"copyLinkFailed": "复制链接失败",
|
||||
"shareLinkAriaLabel": "报告分享链接",
|
||||
"confidenceColumn": "置信度",
|
||||
"confidenceHigh": "高",
|
||||
"confidenceMedium": "中",
|
||||
"confidenceLow": "低",
|
||||
"confidenceInsufficient": "数据不足",
|
||||
"confidenceHighHint": "数据充足,报告结论可靠",
|
||||
"confidenceMediumHint": "数据量一般,建议结合其他信息参考",
|
||||
"confidenceLowHint": "数据量较少,结论仅供参考",
|
||||
"confidenceAriaLabel": "数据置信度:{level}"
|
||||
},
|
||||
"studentDiagnostic": {
|
||||
"noDataDescription": "无法加载学生掌握度数据。",
|
||||
"strengthsDescription": "掌握度较高的知识点。",
|
||||
"weaknessesDescription": "需要关注的知识点。",
|
||||
"diagnosticReportTitle": "诊断报告",
|
||||
"overallLabel": "总体",
|
||||
"historyDescription": "历史诊断报告(按时间倒序)。",
|
||||
"untitledPeriod": "未命名周期",
|
||||
"strengthsListAriaLabel": "优势知识点列表",
|
||||
"weaknessesListAriaLabel": "薄弱知识点列表",
|
||||
"recommendationsListAriaLabel": "学习建议列表",
|
||||
"practiceAriaLabel": "练习知识点:{name}",
|
||||
"noStrengths": "暂无强项知识点。",
|
||||
"noWeaknesses": "暂无弱项知识点。",
|
||||
"reportMeta": "周期:{period} · 总体:{score}%",
|
||||
"historyReportMeta": "{date} · 总体:{score}%"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
},
|
||||
"list": {
|
||||
"empty": "暂无成绩记录",
|
||||
"caption": "成绩记录列表",
|
||||
"deleteAriaLabel": "删除 {studentName} 的 {subjectName} 成绩记录",
|
||||
"editAriaLabel": "编辑 {studentName} 的 {subjectName} 成绩记录",
|
||||
"bulkDelete": "批量删除",
|
||||
"bulkDeleteConfirmation": "确定要删除选中的 {count} 条成绩记录吗?此操作不可撤销。",
|
||||
"bulkDeleteSelected": "已选中 {count} 条记录",
|
||||
"bulkDeleteSuccess": "已删除 {count} 条成绩记录",
|
||||
"bulkDeleteFailed": "批量删除失败",
|
||||
"selectAll": "全选",
|
||||
"selectRow": "选择 {name} 的成绩记录",
|
||||
"clearSelection": "取消选择",
|
||||
"columns": {
|
||||
"student": "学生",
|
||||
"class": "班级",
|
||||
@@ -39,7 +50,8 @@
|
||||
"type": "类型",
|
||||
"semester": "学期",
|
||||
"recordedBy": "录入人",
|
||||
"date": "日期"
|
||||
"date": "日期",
|
||||
"actions": "操作"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -55,7 +67,9 @@
|
||||
"fullScore": "满分",
|
||||
"remark": "备注(可选)",
|
||||
"remarkPlaceholder": "关于此成绩的备注...",
|
||||
"selectPrompt": "请选择班级、科目和学生"
|
||||
"selectPrompt": "请选择班级、科目和学生",
|
||||
"student": "学生",
|
||||
"titleLabel": "标题"
|
||||
},
|
||||
"delete": {
|
||||
"title": "删除成绩记录",
|
||||
@@ -64,11 +78,63 @@
|
||||
"cancel": "取消",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"edit": {
|
||||
"title": "编辑成绩记录",
|
||||
"confirm": "保存修改",
|
||||
"saving": "保存中...",
|
||||
"cancel": "取消",
|
||||
"score": "分数",
|
||||
"fullScore": "满分",
|
||||
"titleLabel": "标题",
|
||||
"remark": "备注(可选)",
|
||||
"remarkPlaceholder": "关于此成绩的备注...",
|
||||
"success": "成绩记录已更新",
|
||||
"failed": "更新失败"
|
||||
},
|
||||
"export": {
|
||||
"detail": "导出成绩明细",
|
||||
"classReport": "导出班级成绩总表",
|
||||
"success": "导出成功",
|
||||
"failed": "导出失败"
|
||||
"failed": "导出失败",
|
||||
"exporting": "导出中...",
|
||||
"selectClassFirst": "请先选择班级",
|
||||
"detailItem": "成绩明细",
|
||||
"classReportItem": "班级成绩总表",
|
||||
"defaultLabel": "导出",
|
||||
"sheets": {
|
||||
"detail": "成绩明细",
|
||||
"summary": "统计汇总",
|
||||
"classReport": "{className}_成绩总表"
|
||||
},
|
||||
"columns": {
|
||||
"studentName": "学生姓名",
|
||||
"class": "班级",
|
||||
"subject": "科目",
|
||||
"title": "标题",
|
||||
"score": "分数",
|
||||
"fullScore": "满分",
|
||||
"type": "类型",
|
||||
"semester": "学期",
|
||||
"recorder": "录入人",
|
||||
"remark": "备注",
|
||||
"date": "录入日期",
|
||||
"metric": "指标",
|
||||
"value": "数值",
|
||||
"total": "总分",
|
||||
"average": "平均分",
|
||||
"rank": "排名"
|
||||
},
|
||||
"metrics": {
|
||||
"average": "均分",
|
||||
"median": "中位数",
|
||||
"max": "最高分",
|
||||
"min": "最低分",
|
||||
"stdDev": "标准差",
|
||||
"passRate": "及格率(%)",
|
||||
"excellentRate": "优秀率(%)",
|
||||
"count": "参考人数",
|
||||
"noData": "无数据"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "统计",
|
||||
@@ -80,7 +146,11 @@
|
||||
"variance": "方差",
|
||||
"passRate": "及格率",
|
||||
"excellentRate": "优秀率",
|
||||
"count": "人数"
|
||||
"count": "人数",
|
||||
"noData": "暂无统计数据。",
|
||||
"stdDevHint": "标准差",
|
||||
"passRateHint": "分数 ≥ 满分的 60%",
|
||||
"excellentRateHint": "分数 ≥ 满分的 85%"
|
||||
},
|
||||
"analytics": {
|
||||
"trend": "成绩趋势",
|
||||
@@ -95,12 +165,22 @@
|
||||
"averageScore": "平均分",
|
||||
"passRate": "及格率",
|
||||
"excellentRate": "优秀率",
|
||||
"studentCount": "学生数"
|
||||
"studentCount": "学生数",
|
||||
"classComparisonLabel": "年级(用于班级对比)",
|
||||
"allOption": "全部",
|
||||
"semester": "学期",
|
||||
"semesterAll": "全部学期",
|
||||
"semester1": "第一学期",
|
||||
"semester2": "第二学期",
|
||||
"exam": "考试",
|
||||
"examAll": "全部考试",
|
||||
"noExams": "暂无关联考试"
|
||||
},
|
||||
"batch": {
|
||||
"title": "批量录入",
|
||||
"saving": "保存中...",
|
||||
"restored": "已恢复未保存的成绩草稿",
|
||||
"restoredFromServer": "已从服务端恢复成绩草稿(跨设备同步)",
|
||||
"invalidScores": "存在无效分数",
|
||||
"fullScoreRequired": "满分必填",
|
||||
"saved": "已录入",
|
||||
@@ -109,7 +189,45 @@
|
||||
"fullScore": "满分",
|
||||
"type": "类型",
|
||||
"saveAll": "全部保存",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"saveAllGrades": "保存全部成绩",
|
||||
"savingGrades": "保存成绩中...",
|
||||
"selectClassAndSubject": "请选择班级和科目",
|
||||
"invalidScoresError": "存在无效分数(超过满分或格式错误),请检查后重试",
|
||||
"enterAtLeastOne": "请至少输入一个分数",
|
||||
"noStudentsInClass": "该班级暂无学生。",
|
||||
"entered": "已录入",
|
||||
"average": "均分",
|
||||
"max": "最高",
|
||||
"min": "最低",
|
||||
"searchStudent": "搜索学生...",
|
||||
"examQuizTitle": "考试 / 测验标题",
|
||||
"fullScoreHint": "满分 {max} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。",
|
||||
"confirmSwitchClass": "当前班级有未保存的成绩记录,确认切换班级?",
|
||||
"invalidScoresBadge": "存在无效分数",
|
||||
"emailColumn": "邮箱",
|
||||
"pasteApplied": "已从剪贴板粘贴 {count} 个分数",
|
||||
"pasteNoMatch": "剪贴板内容无法识别为分数列",
|
||||
"pasteHint": "提示:可从 Excel 复制一列分数粘贴到此处",
|
||||
"undo": "撤销",
|
||||
"undoNoRecord": "无可撤销的录入记录",
|
||||
"undoExpired": "撤销已过期(超过 5 分钟)",
|
||||
"undoFailed": "撤销失败,请重试",
|
||||
"downloadTemplate": "下载模板",
|
||||
"templateAriaLabel": "下载成绩录入模板",
|
||||
"templateStudentName": "学生姓名",
|
||||
"templateScore": "分数",
|
||||
"templateRemark": "备注",
|
||||
"templateFilename": "成绩录入模板.csv",
|
||||
"guide": {
|
||||
"title": "成绩录入指引",
|
||||
"step1": "选择要录入成绩的班级和科目,填写考试或测验标题。",
|
||||
"step2": "在分数列直接输入成绩,也可从 Excel 复制一列分数粘贴到此处。",
|
||||
"step3": "系统每 30 秒自动保存草稿(本地 2 小时有效,服务端 24 小时跨设备同步)。",
|
||||
"step4": "保存成功后可在 5 分钟内点击撤销按钮撤销本次录入。",
|
||||
"dismiss": "知道了",
|
||||
"dismissAriaLabel": "关闭录入指引"
|
||||
}
|
||||
},
|
||||
"trend": {
|
||||
"title": "成绩趋势",
|
||||
@@ -118,12 +236,20 @@
|
||||
"date": "日期"
|
||||
},
|
||||
"summary": {
|
||||
"caption": "学生成绩汇总",
|
||||
"title": "成绩摘要",
|
||||
"averageScore": "平均分",
|
||||
"classRank": "班级排名",
|
||||
"rankValue": "第 {rank} 名",
|
||||
"totalRecords": "总记录数",
|
||||
"highestScore": "最高分",
|
||||
"lowestScore": "最低分"
|
||||
"lowestScore": "最低分",
|
||||
"student": "学生",
|
||||
"gradeHistory": "成绩历史",
|
||||
"noGradesTitle": "暂无成绩",
|
||||
"noGradesDescription": "该学生暂无成绩记录。",
|
||||
"noDataTitle": "暂无数据",
|
||||
"noDataDescription": "学生成绩摘要不可用。"
|
||||
},
|
||||
"empty": {
|
||||
"noRecords": "暂无成绩记录",
|
||||
@@ -139,5 +265,90 @@
|
||||
"failedToCreate": "创建失败",
|
||||
"failedToDelete": "删除失败",
|
||||
"retry": "重试"
|
||||
},
|
||||
"widget": {
|
||||
"error": "组件加载失败",
|
||||
"retry": "重试",
|
||||
"loading": "加载中...",
|
||||
"block": "区块",
|
||||
"loadFailed": "{title}加载失败",
|
||||
"defaultFallback": "请重试或刷新页面",
|
||||
"retryAriaLabel": "重试加载{title}",
|
||||
"loadingAriaLabel": "{title}加载中"
|
||||
},
|
||||
"chart": {
|
||||
"scorePercent": "分数 (%)",
|
||||
"students": "学生数",
|
||||
"averagePercent": "平均分 (%)",
|
||||
"passRatePercent": "及格率 (%)",
|
||||
"excellentPercent": "优秀率 (%)",
|
||||
"average": "平均分",
|
||||
"passRate": "及格率",
|
||||
"classAverage": "班级平均"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "分数分布",
|
||||
"descriptionEmpty": "每个分数区间内的学生人数(标准化为 0-100)。",
|
||||
"descriptionNonEmpty": "{count} 条成绩记录分布在各分数区间。",
|
||||
"emptyTitle": "暂无分布数据",
|
||||
"emptyDescription": "请选择班级和科目查看分数分布。",
|
||||
"ariaLabelEmpty": "分数分布柱状图:暂无数据",
|
||||
"ariaLabelNonEmpty": "分数分布柱状图:共 {count} 条成绩记录分布在 5 个分数区间",
|
||||
"tooltipStudents": "{count} 名学生",
|
||||
"tooltipOfTotal": "占总数的 {percentage}%"
|
||||
},
|
||||
"subjectComparison": {
|
||||
"descriptionEmpty": "对比所选班级各科目的表现。",
|
||||
"descriptionNonEmpty": "各科目的平均分和及格率(标准化为 0-100)。",
|
||||
"emptyTitle": "暂无对比数据",
|
||||
"emptyDescription": "请选择班级对比科目表现。",
|
||||
"ariaLabelEmpty": "科目对比雷达图:暂无数据",
|
||||
"ariaLabelNonEmpty": "科目对比雷达图:共 {count} 个科目的均分与及格率对比"
|
||||
},
|
||||
"classComparison": {
|
||||
"descriptionEmpty": "对比各班级的平均分、及格率和优秀率。",
|
||||
"descriptionNonEmpty": "各班级的平均分、及格率(≥60%)和优秀率(≥85%)。",
|
||||
"emptyTitle": "暂无对比数据",
|
||||
"emptyDescription": "请选择年级和科目对比班级。",
|
||||
"ariaLabelEmpty": "班级对比柱状图:暂无数据",
|
||||
"ariaLabelNonEmpty": "班级对比柱状图:共 {count} 个班级的均分、及格率与优秀率对比",
|
||||
"significanceTitle": "显著性分析",
|
||||
"significanceRange": "班级间最大差异为 {range} 分",
|
||||
"significanceHigh": "差异显著",
|
||||
"significanceMedium": "可能存在差异",
|
||||
"significanceLow": "差异不显著",
|
||||
"significanceHighHint": "极差较大且各班样本量充足(≥30),班级间表现差异具有统计意义。",
|
||||
"significanceMediumHint": "存在一定差异,但可能受样本量或随机波动影响,建议进一步分析。",
|
||||
"significanceLowHint": "极差较小,班级间表现接近,差异不具统计意义。",
|
||||
"significanceAriaLabel": "显著性分析:{level}",
|
||||
"significanceDetails": "查看详细分析",
|
||||
"significanceTopClass": "最高分班级:{name}({score} 分)",
|
||||
"significanceBottomClass": "最低分班级:{name}({score} 分)"
|
||||
},
|
||||
"trendChart": {
|
||||
"descriptionEmpty": "随时间变化的分数趋势(标准化为 0-100)。",
|
||||
"descriptionNonEmpty": "{label} · 平均 {average}%",
|
||||
"emptyTitle": "暂无趋势数据",
|
||||
"emptyDescription": "请选择班级和科目查看成绩趋势。",
|
||||
"ariaLabelEmpty": "成绩趋势图:暂无数据",
|
||||
"ariaLabelNonEmpty": "成绩趋势图:{label},平均 {average}%"
|
||||
},
|
||||
"trendCard": {
|
||||
"emptyDescription": "添加成绩记录后,成绩趋势将显示在此处。",
|
||||
"ariaLabelEmpty": "成绩趋势图:暂无数据",
|
||||
"ariaLabelNonEmpty": "成绩趋势图:共 {count} 次成绩记录",
|
||||
"rangeAriaLabel": "选择日期范围"
|
||||
},
|
||||
"rankingTrend": {
|
||||
"ariaLabelEmpty": "排名趋势图:暂无数据",
|
||||
"ariaLabelNonEmpty": "排名趋势图:共 {count} 次排名记录"
|
||||
},
|
||||
"classReport": {
|
||||
"noDataTitle": "暂无数据",
|
||||
"noDataDescription": "该班级暂无成绩记录。",
|
||||
"classRanking": "班级排名",
|
||||
"rankColumn": "排名",
|
||||
"recordsColumn": "记录数",
|
||||
"studentCountInfo": "{studentCount} 名学生 · {recordCount} 条成绩记录"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,34 @@
|
||||
"unknownBlockType": "未知节点类型",
|
||||
"nodeSummaryEmpty": "空",
|
||||
"questionCount": "{count} 道题",
|
||||
"charCount": "{count} 字"
|
||||
"charCount": "{count} 字",
|
||||
"itemCount": "{count} 项",
|
||||
"pointCount": "{count} 个要点",
|
||||
"assignmentCount": "{count} 个作业",
|
||||
"durationMin": "{count} 分钟",
|
||||
"textbookContent": "课文正文",
|
||||
"textbookContentMissing": "正文节点缺失",
|
||||
"textbookContentEmpty": "(未加载课文内容)",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"rangeAnchorTitle": "为选中文本关联节点",
|
||||
"pointAnchorTitle": "在此位置插入占位符 {number}",
|
||||
"anchorToSelectedNode": "关联到当前选中节点",
|
||||
"anchorToNewNode": "关联到新节点"
|
||||
},
|
||||
"picker": {
|
||||
"textbookLabel": "选择教材",
|
||||
"chapterLabel": "选择课文",
|
||||
"selectTextbook": "请选择教材...",
|
||||
"selectChapter": "请选择课文...",
|
||||
"selectTextbookFirst": "请先选择教材",
|
||||
"loadingTextbooks": "加载教材中...",
|
||||
"loadingChapters": "加载章节中...",
|
||||
"noTextbooks": "暂无可用教材,请先在教材模块创建",
|
||||
"noChapters": "该教材暂无章节",
|
||||
"selectedChapter": "已选课文:{chapter}",
|
||||
"skeletonHint": "将自动生成以课文正文为核心的备课骨架(含 10 个教学节点)",
|
||||
"errorTextbookChapterRequired": "请选择教材和课文"
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "搜索标题...",
|
||||
@@ -126,13 +153,15 @@
|
||||
"linked": "已关联 {count} 个知识点",
|
||||
"annotate": "标注知识点",
|
||||
"select": "选择知识点",
|
||||
"selected": "已选 {count} 个"
|
||||
"selected": "已选 {count} 个",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"questionBank": {
|
||||
"title": "从题库选择题目",
|
||||
"add": "添加",
|
||||
"insert": "插入",
|
||||
"selected": "已选 {count} 题",
|
||||
"loading": "加载中...",
|
||||
"source": {
|
||||
"bank": "题库",
|
||||
"inline": "新建"
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
"profile": {
|
||||
"title": "个人信息",
|
||||
"description": "更新您的个人资料。",
|
||||
"avatar": {
|
||||
"upload": "上传头像",
|
||||
"remove": "移除",
|
||||
"hint": "支持 JPG、PNG、WebP、GIF,最大 2MB",
|
||||
"uploadSuccess": "头像更新成功",
|
||||
"uploadFailure": "头像上传失败",
|
||||
"removeSuccess": "头像已移除",
|
||||
"removeFailure": "移除头像失败",
|
||||
"invalidType": "仅支持 JPG、PNG、WebP、GIF 格式",
|
||||
"tooLarge": "文件大小不能超过 2MB",
|
||||
"tooLongName": "文件名过长,请重命名后再上传(最多 255 字符)"
|
||||
},
|
||||
"fields": {
|
||||
"name": "姓名",
|
||||
"namePlaceholder": "您的姓名",
|
||||
@@ -68,7 +80,10 @@
|
||||
"save": "保存偏好",
|
||||
"saving": "保存中...",
|
||||
"success": "通知偏好已更新",
|
||||
"failure": "通知偏好更新失败"
|
||||
"failure": "通知偏好更新失败",
|
||||
"test": "测试",
|
||||
"testSuccess": "测试通知已发送",
|
||||
"testFailure": "测试通知发送失败"
|
||||
},
|
||||
"appearance": {
|
||||
"theme": {
|
||||
@@ -120,6 +135,63 @@
|
||||
"tip2": "避免使用常见词汇、姓名或连续模式。",
|
||||
"tip3": "定期更换密码。",
|
||||
"tip4": "多次登录失败后账户将被临时锁定。"
|
||||
},
|
||||
"center": {
|
||||
"title": "安全中心",
|
||||
"description": "管理两步验证和查看登录活动。",
|
||||
"twoFactor": {
|
||||
"title": "两步验证(2FA)",
|
||||
"description": "启用后,登录时需要输入手机验证器生成的一次性码。",
|
||||
"enabled": "已启用",
|
||||
"hint": "启用 2FA 可显著提升账户安全性,防止密码泄露导致的未授权访问。",
|
||||
"enable": "启用",
|
||||
"disable": "关闭",
|
||||
"enableSuccess": "两步验证已启用",
|
||||
"disableSuccess": "两步验证已关闭",
|
||||
"toggleFailure": "操作失败,请稍后重试",
|
||||
"setupFailure": "生成二维码失败,请稍后重试",
|
||||
"verifyFailure": "验证失败,请稍后重试",
|
||||
"disableFailure": "关闭失败,请稍后重试",
|
||||
"regenerateFailure": "重新生成备份码失败,请稍后重试",
|
||||
"regenerateSuccess": "备份码已重新生成",
|
||||
"invalidCode": "验证码无效,请重新输入",
|
||||
"scanQr": "使用验证器 App(如 Google Authenticator、Microsoft Authenticator)扫描上方二维码",
|
||||
"manualEntry": "无法扫码?手动输入此密钥",
|
||||
"enterCode": "输入验证器显示的 6 位数字",
|
||||
"enterCodeDisable": "输入一次性码或备份码以确认关闭 2FA",
|
||||
"enterCodeRegen": "输入当前的一次性码以重新生成备份码",
|
||||
"verify": "验证并启用",
|
||||
"cancel": "取消",
|
||||
"done": "完成",
|
||||
"backupCodes": "备份码",
|
||||
"backupHint": "无法访问验证器时用于恢复账户",
|
||||
"backupRemaining": "剩余 {count} 个备份码",
|
||||
"backupWarning": "请妥善保存这些备份码。每个备份码只能使用一次。丢失后无法找回,只能重新生成。",
|
||||
"copy": "复制全部",
|
||||
"copied": "已复制",
|
||||
"regenerate": "重新生成",
|
||||
"disableTitle": "关闭两步验证",
|
||||
"disableDescription": "关闭后,登录将不再需要一次性码。为确认是本人操作,请输入当前的一次性码或备份码。",
|
||||
"regenerateTitle": "重新生成备份码",
|
||||
"regenerateDescription": "此操作将使所有旧备份码失效,并生成 10 个新备份码。请输入当前的一次性码以确认。"
|
||||
},
|
||||
"recentLogins": {
|
||||
"title": "最近登录",
|
||||
"showingLatest": "最近 {count} 条记录",
|
||||
"empty": "暂无登录记录",
|
||||
"failed": "失败",
|
||||
"current": "当前会话",
|
||||
"revokeAll": "登出所有其他会话",
|
||||
"revoking": "处理中...",
|
||||
"revokeSuccess": "已登出 {count} 个会话",
|
||||
"revokeSuccessEmpty": "无其他活跃会话需要登出",
|
||||
"revokeFailure": "登出会话失败,请稍后重试",
|
||||
"actions": {
|
||||
"signin": "登录",
|
||||
"signout": "退出",
|
||||
"signup": "注册"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
171
src/shared/lib/action-utils.ts
Normal file
171
src/shared/lib/action-utils.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 共享错误处理工具:统一 Server Action 的错误响应与客户端 Action 调用模式。
|
||||
*
|
||||
* 设计目标:
|
||||
* 1. 避免将内部错误消息(如 SQL 错误、堆栈信息)直接暴露给客户端
|
||||
* 2. 统一 ActionState<T> 的失败结构
|
||||
* 3. 为客户端调用 Server Action 提供 try/catch/finally 包装,防止 UI 永久卡 loading
|
||||
*/
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
|
||||
/**
|
||||
* 已知的业务错误类型,消息可以安全返回给客户端。
|
||||
* 其他 Error 一律视为系统错误,返回通用消息。
|
||||
*/
|
||||
export class BusinessError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "BusinessError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在错误。消息可安全返回客户端。
|
||||
*/
|
||||
export class NotFoundError extends BusinessError {
|
||||
constructor(resource: string) {
|
||||
super(`${resource} 不存在`, "not_found")
|
||||
this.name = "NotFoundError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入校验错误。消息可安全返回客户端。
|
||||
*/
|
||||
export class ValidationError extends BusinessError {
|
||||
constructor(message: string) {
|
||||
super(message, "validation_error")
|
||||
this.name = "ValidationError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 Server Action 错误处理器。
|
||||
*
|
||||
* - PermissionDeniedError:返回权限不足消息(可安全暴露)
|
||||
* - BusinessError / NotFoundError / ValidationError:返回其 message(可安全暴露)
|
||||
* - 其他 Error:返回通用消息,原始错误通过 console.error 记录到服务端日志
|
||||
*
|
||||
* @returns ActionState<never> 的失败分支
|
||||
*/
|
||||
export function handleActionError(e: unknown): ActionState<never> {
|
||||
// 权限错误:消息已由 PermissionDeniedError 构造为用户友好文案
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
|
||||
// 业务错误:消息可安全暴露
|
||||
if (e instanceof BusinessError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
|
||||
// 未知错误:不暴露内部细节,仅记录服务端日志
|
||||
if (e instanceof Error) {
|
||||
console.error("[ActionError]", e.name, e.message, e.stack)
|
||||
return { success: false, message: "操作失败,请稍后重试" }
|
||||
}
|
||||
|
||||
console.error("[ActionError] Unknown error:", e)
|
||||
return { success: false, message: "操作失败,请稍后重试" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地调用 Server Action,自动处理 try/catch/finally。
|
||||
*
|
||||
* 用于客户端组件中调用 Server Action,确保:
|
||||
* 1. 网络错误或 Action 抛出异常时,catch 块执行 onError 回调
|
||||
* 2. 无论成功失败,finally 块执行 onFinally 回调(用于重置 loading 状态)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
* const result = await safeActionCall(
|
||||
* () => createGradeRecordAction(null, formData),
|
||||
* {
|
||||
* onError: () => toast.error("保存失败"),
|
||||
* onFinally: () => setIsSubmitting(false),
|
||||
* }
|
||||
* )
|
||||
* if (result?.success) { toast.success("保存成功") }
|
||||
* ```
|
||||
*/
|
||||
export async function safeActionCall<T>(
|
||||
action: () => Promise<ActionState<T>>,
|
||||
options?: {
|
||||
onError?: (error: unknown) => void
|
||||
onFinally?: () => void
|
||||
}
|
||||
): Promise<ActionState<T> | null> {
|
||||
try {
|
||||
return await action()
|
||||
} catch (e) {
|
||||
// Action 抛出异常(非返回 failure),如网络错误、序列化错误等
|
||||
options?.onError?.(e)
|
||||
console.error("[SafeActionCall]", e)
|
||||
return null
|
||||
} finally {
|
||||
options?.onFinally?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析 JSON 字符串,失败时抛出 ValidationError。
|
||||
*
|
||||
* 用于 Server Action 中包装 JSON.parse,避免 SyntaxError 被外层 catch
|
||||
* 捕获后暴露解析细节给客户端。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const records = safeJsonParse(recordsJson, "成绩数据格式无效")
|
||||
* ```
|
||||
*/
|
||||
export function safeJsonParse<T>(json: string, errorMessage: string): T {
|
||||
try {
|
||||
return JSON.parse(json) as T
|
||||
} catch {
|
||||
throw new ValidationError(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验日期字符串是否有效,无效则抛出 ValidationError。
|
||||
*
|
||||
* @returns 解析后的 Date 对象
|
||||
*/
|
||||
export function safeParseDate(value: string, fieldName: string): Date {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new ValidationError(`${fieldName} 格式无效`)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验数字字符串,无效则抛出 ValidationError。
|
||||
*
|
||||
* @returns 解析后的 number
|
||||
*/
|
||||
export function safeParseNumber(value: string, fieldName: string): number {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new ValidationError(`${fieldName} 必须是有效数字`)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 SQL LIKE 通配符(% 和 _),防止用户输入干扰模糊查询。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const needle = `%${escapeLikePattern(q)}%`
|
||||
* ```
|
||||
*/
|
||||
export function escapeLikePattern(input: string): string {
|
||||
return input.replace(/[%_\\]/g, "\\$&")
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.FILE_READ,
|
||||
Permissions.FILE_DELETE,
|
||||
Permissions.DASHBOARD_ADMIN_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -107,6 +108,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.LESSON_PLAN_DELETE,
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
Permissions.DASHBOARD_TEACHER_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
@@ -128,6 +130,8 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.ELECTIVE_READ,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.DASHBOARD_STUDENT_READ,
|
||||
Permissions.ERROR_BOOK_READ,
|
||||
Permissions.ERROR_BOOK_MANAGE,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
@@ -141,6 +145,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.MESSAGE_READ,
|
||||
Permissions.MESSAGE_DELETE,
|
||||
Permissions.DASHBOARD_PARENT_READ,
|
||||
Permissions.ERROR_BOOK_READ,
|
||||
],
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -178,6 +183,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.EXAM_PROCTOR_READ,
|
||||
Permissions.DIAGNOSTIC_MANAGE,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
],
|
||||
teaching_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
@@ -210,6 +216,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
Permissions.ELECTIVE_READ,
|
||||
Permissions.EXAM_PROCTOR_READ,
|
||||
Permissions.DIAGNOSTIC_READ,
|
||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,14 @@ export const Permissions = {
|
||||
DASHBOARD_TEACHER_READ: "dashboard:teacher_read",
|
||||
DASHBOARD_STUDENT_READ: "dashboard:student_read",
|
||||
DASHBOARD_PARENT_READ: "dashboard:parent_read",
|
||||
|
||||
// Error Book (错题本)
|
||||
/** 读取自己的错题本(学生)或子女的错题本(家长) */
|
||||
ERROR_BOOK_READ: "error_book:read",
|
||||
/** 管理自己的错题本条目:手动添加、编辑笔记、标记掌握、删除 */
|
||||
ERROR_BOOK_MANAGE: "error_book:manage",
|
||||
/** 读取班级/年级/全校错题统计分析(教师、年级主任、教务主任、管理员) */
|
||||
ERROR_BOOK_ANALYTICS_READ: "error_book:analytics_read",
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
|
||||
Reference in New Issue
Block a user