feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

shared:

- Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components

- Add ui/alert component

- Add constants directory

- Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib

- Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac

tests:

- Add integration tests for elective

- Add tests/setup/empty-stub

scripts:

- Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
SpecialX
2026-07-03 10:26:38 +08:00
parent 21142f9b99
commit 0e63c24ed9
97 changed files with 9753 additions and 819 deletions

View File

@@ -0,0 +1,139 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge"
export interface ClassFilterItem {
classId: string
className: string
/** 可选徽章文本(已翻译,如 "5 次练习" 或 "3 错题" */
badgeText?: string
/** 可选次要信息文本(如 "2 待复习" */
secondaryText?: string
/** 次要信息是否使用警告样式(红色) */
secondaryWarning?: boolean
}
interface ClassFilterProps {
classes: ClassFilterItem[]
/** 当前选中的班级 ID"all" 表示全部) */
currentClassId: string
/** URL 参数名(默认 "classId" */
paramName?: string
/** "全部班级" 按钮文本(已翻译) */
allLabel: string
/** 无障碍标签(已翻译,如 "选择班级" */
ariaLabel?: string
}
/**
* 通用班级筛选器(共享组件)。
*
* 通过 URL 查询参数(默认 `classId`)管理选中状态,点击切换班级。
*
* 解耦设计:
* - 不依赖任何业务模块的类型或翻译
* - 所有展示文本allLabel、badgeText、secondaryText由调用方翻译后传入
* - 适用于教师/年级主任在 error-book、practice、homework 等模块的班级切换
*
* @example
* // Server Component 页面调用
* <ClassFilter
* classes={classes.map(c => ({
* classId: c.classId,
* className: c.className,
* badgeText: t("classFilter.sessionCount", { count: c.totalSessions }),
* }))}
* currentClassId={effectiveClassId}
* allLabel={t("classFilter.all")}
* ariaLabel={t("classFilter.selectClass")}
* />
*/
export function ClassFilter({
classes,
currentClassId,
paramName = "classId",
allLabel,
ariaLabel,
}: ClassFilterProps): React.ReactNode {
const router = useRouter()
const searchParams = useSearchParams()
function handleSelect(classId: string): void {
const params = new URLSearchParams(searchParams.toString())
if (classId === "all") {
params.delete(paramName)
} else {
params.set(paramName, classId)
}
router.push(`?${params.toString()}`, { scroll: false })
}
if (classes.length === 0) return null
return (
<div
className="flex flex-wrap gap-2"
role="tablist"
aria-label={ariaLabel}
>
<button
type="button"
role="tab"
aria-selected={currentClassId === "all"}
onClick={() => handleSelect("all")}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
currentClassId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted",
)}
>
<span className="font-medium">{allLabel}</span>
</button>
{classes.map((cls) => {
const isActive = currentClassId === cls.classId
return (
<button
key={cls.classId}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(cls.classId)}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted",
)}
>
<span className="font-medium">{cls.className}</span>
{cls.badgeText ? (
<Badge
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{cls.badgeText}
</Badge>
) : null}
{cls.secondaryText ? (
<span
className={cn(
"text-xs",
cls.secondaryWarning
? isActive
? "text-primary-foreground/80"
: "text-rose-600"
: "text-muted-foreground",
)}
>
{cls.secondaryText}
</span>
) : null}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { AlertTriangle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
/**
* 通用错误边界展示组件P1-4 审计修复)。
*
* 统一 error.tsx 的渲染逻辑,避免 30+ error.tsx 重复同样的代码。
* 各模块 error.tsx 只需调用 `<ErrorState error={error} reset={reset} namespace="xxx" />`。
*
* 翻译键查找规则:在传入 namespace 下查找 error.title / error.description / error.retry。
*/
export function ErrorState({
error,
reset,
namespace,
}: {
error: Error & { digest?: string }
reset: () => void
namespace: string
}) {
const t = useTranslations(namespace)
return (
<EmptyState
icon={AlertTriangle}
title={t("error.title")}
description={error.message || t("error.description")}
action={{ label: t("error.retry"), onClick: reset }}
/>
)
}

View File

@@ -1,313 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { cn } from "@/shared/lib/utils"
import { Permissions } from "@/shared/types/permissions"
type Role = "student" | "teacher" | "parent" | "admin"
const TEACHER_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
type TeacherSubject = (typeof TEACHER_SUBJECTS)[number]
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null
}
export function OnboardingGate() {
const router = useRouter()
const { status, data: session, update } = useSession()
const [required, setRequired] = useState(false)
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [role, setRole] = useState<Role>("student")
const [name, setName] = useState("")
const [phone, setPhone] = useState("")
const [address, setAddress] = useState("")
const [classCodes, setClassCodes] = useState("")
const [teacherSubjects, setTeacherSubjects] = useState<TeacherSubject[]>([])
const canClose = useMemo(() => !required, [required])
useEffect(() => {
if (status !== "authenticated") return
let cancelled = false
;(async () => {
const res = await fetch("/api/onboarding/status", { cache: "no-store" }).catch(() => null)
const json = res ? await res.json().catch(() => null) : null
if (cancelled) return
if (isRecord(json)) {
const required = Boolean(json.required)
const role = String(json.role ?? "student") as Role
setRequired(required)
setRole(role === "admin" ? "admin" : role)
setName(String(session?.user?.name ?? "").trim())
if (required) {
setOpen(true)
setStep(0)
}
}
})()
return () => {
cancelled = true
}
}, [status, session?.user?.name])
useEffect(() => {
if (!open) return
if (!required) return
setOpen(true)
}, [open, required])
const title =
step === 0 ? "角色选择" : step === 1 ? "通用信息" : step === 2 ? "角色信息(可跳过)" : "完成"
const description =
step === 0
? "请选择你在系统中的角色"
: step === 1
? "填写姓名、电话、住址等信息"
: step === 2
? "不同角色可配置班级代码、教学科目等"
: "配置完成,可以进入系统"
const canNextFromStep0 = role.length > 0
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
const permissions = (session?.user?.permissions ?? []) as string[]
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
const onNext = async () => {
if (step === 0) {
if (!canNextFromStep0) return
setStep(1)
return
}
if (step === 1) {
if (!canNextFromStep1) {
toast.error("请填写姓名与电话")
return
}
if (isAdmin) {
setStep(3)
} else {
setStep(2)
}
return
}
if (step === 2) {
setStep(3)
return
}
}
const onBack = () => {
if (step === 0) return
setStep((s) => Math.max(0, s - 1))
}
const toggleSubject = (subject: TeacherSubject) => {
setTeacherSubjects((prev) => (prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]))
}
const onFinish = async () => {
setIsSubmitting(true)
try {
const res = await fetch("/api/onboarding/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
role,
name,
phone,
address,
classCodes,
teacherSubjects,
}),
})
const json = await res.json().catch(() => null)
if (!res.ok || !isRecord(json) || json.success !== true) {
const msg = isRecord(json) ? String(json.message ?? "") : ""
throw new Error(msg || "提交失败")
}
await update?.()
toast.success("配置完成")
setRequired(false)
setOpen(false)
router.push("/dashboard")
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : "提交失败"
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (canClose) setOpen(v)
else setOpen(true)
}}
>
<DialogContent className="sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className={cn("h-1 flex-1 rounded", step >= 0 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 1 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 2 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 3 ? "bg-primary" : "bg-muted")} />
</div>
{step === 0 ? (
<div className="grid gap-2">
<Label>Role</Label>
{isAdmin ? (
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
) : (
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="student">student</SelectItem>
<SelectItem value="teacher">teacher</SelectItem>
<SelectItem value="parent">parent</SelectItem>
</SelectContent>
</Select>
)}
</div>
) : null}
{step === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="onb_name"></Label>
<Input id="onb_name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_phone"></Label>
<Input id="onb_phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_address"></Label>
<Input id="onb_address" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-4">
{isTeacher ? (
<>
<div className="grid gap-2">
<Label htmlFor="onb_codes_teacher"></Label>
<Textarea
id="onb_codes_teacher"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{TEACHER_SUBJECTS.map((s) => (
<label key={s} className="flex items-center gap-2 text-sm">
<Checkbox checked={teacherSubjects.includes(s)} onCheckedChange={() => toggleSubject(s)} />
{s}
</label>
))}
</div>
</div>
</>
) : null}
{isStudent ? (
<div className="grid gap-2">
<Label htmlFor="onb_codes_student"></Label>
<Textarea
id="onb_codes_student"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
) : null}
{isParent ? (
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
</div>
) : null}
</div>
) : null}
{step === 3 ? (
<div className="rounded-md border px-3 py-3 text-sm">
<div className="font-medium"></div>
<div className="text-muted-foreground"></div>
</div>
) : null}
</div>
<DialogFooter>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-between">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onBack} disabled={step === 0 || isSubmitting}>
</Button>
{step === 2 ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep(3)}
disabled={isSubmitting}
>
</Button>
) : null}
</div>
<div className="flex gap-2 justify-end">
{step < 3 ? (
<Button type="button" onClick={onNext} disabled={isSubmitting}>
</Button>
) : (
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
/**
* 通用路由错误边界组件。
* 供各模块 error.tsx 复用消除重复代码P1-10 重构)。
*
* @example
* ```tsx
* // app/(dashboard)/admin/attendance/error.tsx
* "use client"
* import { RouteErrorBoundary } from "@/shared/components/route-error"
* export default function Error({ reset }: { error: Error; reset: () => void }) {
* return <RouteErrorBoundary reset={reset} namespace="attendance" />
* }
* ```
*/
export function RouteErrorBoundary({
reset,
namespace = "attendance",
}: {
reset: () => void
namespace?: string
}) {
const t = useTranslations(namespace)
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
action={{
label: t("actions.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import type { JSX } from "react"
import { Component, type ErrorInfo, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { AlertCircle, RefreshCw } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
interface BoundaryProps {
children: ReactNode
/** 自定义降级 UI函数形式优先级最高。若提供则忽略 title/description/retryLabel */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 渲染失败时的标题(已国际化,默认取 {namespace}.error.boundaryTitle */
title?: string
/** 渲染失败时的描述(已国际化,默认取 {namespace}.error.boundaryDescription */
description?: string
/** 重试按钮文案(已国际化,默认取 {namespace}.error.retry */
retryLabel?: string
/** 错误回调(用于埋点/监控) */
onError?: (error: Error, info: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
/**
* 组件级 Error Boundary类组件实现承载错误捕获能力
* 不直接使用,请使用默认导出的函数式 `SectionErrorBoundary` 包装器以获得 i18n 支持。
*
* 支持两种降级形式:
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI优先级最高
* 2. `title` / `description` / `retryLabel` — 默认降级 UI + i18n 文案
*/
class SectionErrorBoundaryBase extends Component<BoundaryProps, State> {
constructor(props: BoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[SectionErrorBoundary]", error, errorInfo)
this.props.onError?.(error, errorInfo)
}
private handleRetry = (): void => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError && this.state.error) {
// 优先使用自定义 fallback
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleRetry)
}
// 默认降级 UI
return (
<div
role="alert"
aria-live="assertive"
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
>
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">
{this.props.title ?? "加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.description ?? "数据加载时发生错误,请重试。"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
aria-label={this.props.retryLabel ?? "重试"}
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}
interface SectionErrorBoundaryProps {
children: ReactNode
/** i18n namespace默认 "common"。需保证该 namespace 下存在 error.boundaryTitle / error.boundaryDescription / error.retry 键 */
namespace?: string
/** 自定义降级 UI函数形式优先级最高。若提供则忽略 title/description/retryLabel */
fallback?: (error: Error, reset: () => void) => ReactNode
/** 显式覆盖标题(优先级高于 i18n 默认) */
title?: string
/** 显式覆盖描述 */
description?: string
/** 显式覆盖重试按钮文案 */
retryLabel?: string
/** 错误回调(用于埋点/监控) */
onError?: (error: Error, info: ErrorInfo) => void
}
/**
* 组件级 Error Boundary包裹独立数据区块防止单区块错误导致整页崩溃。
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
*
* @example
* ```tsx
* // 默认 i18n 文案
* <SectionErrorBoundary namespace="coursePlans">
* <CoursePlanProgress plan={plan} />
* </SectionErrorBoundary>
*
* // 完全自定义降级 UI
* <SectionErrorBoundary fallback={(error, reset) => <CustomUI error={error} onRetry={reset} />}>
* <CoursePlanProgress plan={plan} />
* </SectionErrorBoundary>
* ```
*/
export function SectionErrorBoundary({
children,
namespace = "common",
fallback,
title,
description,
retryLabel,
onError,
}: SectionErrorBoundaryProps): JSX.Element {
const t = useTranslations(namespace)
return (
<SectionErrorBoundaryBase
fallback={fallback}
title={title ?? t("error.boundaryTitle")}
description={description ?? t("error.boundaryDescription")}
retryLabel={retryLabel ?? t("error.retry")}
onError={onError}
>
{children}
</SectionErrorBoundaryBase>
)
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { cn } from "@/shared/lib/utils"
function Alert({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="alert"
data-slot="alert"
className={cn(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
className
)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,155 @@
"use client"
/**
* 通用 Widget 边界组件shared 层)。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 用法:
* ```tsx
* <WidgetBoundary title="成绩趋势">
* <GradeTrendChart data={data} />
* </WidgetBoundary>
* ```
*/
import { Component, Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
interface WidgetBoundaryProps {
children: ReactNode
/** Widget 标题(用于错误提示和 aria-label */
title?: string
/** 骨架屏高度(默认 200px */
skeletonHeight?: number
/** 自定义错误描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface WidgetBoundaryState {
hasError: boolean
}
interface WidgetErrorBoundaryProps {
title: string
fallbackDescription: string
retryLabel: string
loadFailedMessage: string
retryAriaLabel: string
children: ReactNode
}
class WidgetErrorBoundary extends Component<
WidgetErrorBoundaryProps,
WidgetBoundaryState
> {
constructor(props: WidgetErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): WidgetBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div
role="alert"
aria-live="assertive"
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
>
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.loadFailedMessage}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={this.props.retryAriaLabel}
>
{this.props.retryLabel}
</Button>
</div>
)
}
return this.props.children
}
}
function WidgetSkeleton({
height,
loadingAriaLabel,
}: {
height: number
loadingAriaLabel: string
}): ReactNode {
return (
<div
role="status"
aria-label={loadingAriaLabel}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
)
}
export function WidgetBoundary({
children,
title,
skeletonHeight = 200,
fallbackDescription,
retryLabel,
}: WidgetBoundaryProps): ReactNode {
const t = useTranslations("common")
const effectiveTitle = title ?? t("widget.block")
const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
const effectiveRetryLabel = retryLabel ?? t("widget.retry")
const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
return (
<WidgetErrorBoundary
title={effectiveTitle}
fallbackDescription={effectiveFallbackDescription}
retryLabel={effectiveRetryLabel}
loadFailedMessage={loadFailedMessage}
retryAriaLabel={retryAriaLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
}
>
{children}
</Suspense>
</WidgetErrorBoundary>
)
}

View File

@@ -0,0 +1,96 @@
/**
* 考勤状态共享常量。
*
* 考勤状态是跨模块的 UI 关注点attendance / parent / 未来可能的 notifications 等
* 均需使用),因此类型与视觉映射下沉至 shared 层,避免业务模块间相互依赖。
*
* 规则遵循:`shared/` 不得反向依赖任何 `modules/*`。
*
* L-1 演进:新增 `school_activity`(校内活动请假,如运动会、研学、社团活动)。
*/
/** 考勤状态枚举(跨模块共享)。 */
export type AttendanceStatus =
| "present"
| "absent"
| "late"
| "early_leave"
| "excused"
| "school_activity"
/** 考勤状态选项(顺序即 UI 展示顺序)。 */
export const ATTENDANCE_STATUS_OPTIONS: AttendanceStatus[] = [
"present",
"absent",
"late",
"early_leave",
"excused",
"school_activity",
]
/** 考勤状态 → i18n key 映射(组件层 `t(key)` 解析namespace 为 attendance。 */
export const ATTENDANCE_STATUS_LABEL_KEYS: Record<AttendanceStatus, string> = {
present: "status.present",
absent: "status.absent",
late: "status.late",
early_leave: "status.early_leave",
excused: "status.excused",
school_activity: "status.school_activity",
}
/** 考勤状态 → Badge variant 映射。 */
export const ATTENDANCE_STATUS_BADGE_VARIANTS: Record<
AttendanceStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
present: "default",
absent: "destructive",
late: "secondary",
early_leave: "outline",
excused: "outline",
school_activity: "outline",
}
/** 考勤状态 → Tailwind 圆点颜色类。 */
export const ATTENDANCE_STATUS_DOT_COLORS: Record<AttendanceStatus, string> = {
present: "bg-green-500",
absent: "bg-red-500",
late: "bg-yellow-500",
early_leave: "bg-blue-500",
excused: "bg-purple-500",
school_activity: "bg-cyan-500",
}
/** 键盘快捷键映射(考勤点名场景)。 */
export const ATTENDANCE_STATUS_SHORTCUTS: Record<string, AttendanceStatus> = {
p: "present",
a: "absent",
l: "late",
e: "early_leave",
x: "excused",
s: "school_activity",
}
/** 初始化状态计数,避免 `{} as Record<...>` 类型断言。 */
export function createInitialStatusCounts(): Record<AttendanceStatus, number> {
return {
present: 0,
absent: 0,
late: 0,
early_leave: 0,
excused: 0,
school_activity: 0,
}
}
/** 类型守卫:验证字符串是否为合法考勤状态。 */
export function isAttendanceStatus(v: unknown): v is AttendanceStatus {
return (
v === "present" ||
v === "absent" ||
v === "late" ||
v === "early_leave" ||
v === "excused" ||
v === "school_activity"
)
}

View File

@@ -80,6 +80,20 @@ export const accounts = mysqlTable("accounts", {
})
);
/**
* @deprecated audit-P1-9此表在 JWT 策略下从未被写入数据。
*
* NextAuth 配置为 `session: { strategy: "jwt" }`(见 `src/auth.ts`
* 所有 session 信息存储在 JWT cookie 中,不写入此表。
* `revokeAllOtherSessionsAction` 已改为 no-op返回 0 并记录日志)。
* `getUsersDashboardStats` 已不再查询此表。
*
* 此表定义暂时保留是为了:
* 1. 避免删除表定义后 drizzle-kit 生成 DROP TABLE 迁移误删生产数据
* 2. 未来若切换为 `strategy: "database"` 或引入 JWT 黑名单可复用
*
* 后续应在显式迁移中删除此表(同时删除 relations.ts 中的 sessionsRelations
*/
// Auth.js: Sessions
export const sessions = mysqlTable("sessions", {
sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(),
@@ -106,6 +120,12 @@ export const roles = mysqlTable("roles", {
id: id("id").primaryKey(),
name: varchar("name", { length: 50 }).notNull().unique(), // e.g., 'admin', 'teacher', 'student', 'grade_head'
description: varchar("description", { length: 255 }),
// Builtin roles (admin/teacher/student/parent/grade_head/teaching_head) are
// seeded with is_system = true and cannot be deleted. The `admin` role is
// additionally fully locked (no permission edits, no disable, no rename).
isSystem: boolean("is_system").default(false).notNull(),
// Disabled roles do not contribute permissions at login time.
isEnabled: boolean("is_enabled").default(true).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
});
@@ -501,13 +521,14 @@ export const exams = mysqlTable("exams", {
/**
* Stores the hierarchical structure of the exam.
* Expected JSON Schema:
* Expected JSON Schema (3-level: section > group > question):
* type ExamStructure = Array<
* | { type: 'group', title: string, children: ExamStructure }
* | { type: 'section', title: string, level?: number, children: ExamStructure }
* | { type: 'group', title: string, instruction?: string, children: ExamStructure }
* | { type: 'question', questionId: string, score: number }
* >
*
* Note: This is for UI presentation/ordering.
*
* Note: This is for UI presentation/ordering.
* Real relational integrity is maintained in 'exam_questions' table.
*/
structure: json("structure"),
@@ -705,7 +726,7 @@ export const aiProviderVisibilityEnum = mysqlEnum("visibility", ["public", "priv
export const aiProviders = mysqlTable("ai_providers", {
id: id("id").primaryKey(),
provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom"]).notNull(),
provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom", "ollama"]).notNull(),
baseUrl: varchar("base_url", { length: 512 }),
model: varchar("model", { length: 128 }).notNull(),
apiKeyEncrypted: text("api_key_encrypted").notNull(),
@@ -813,6 +834,44 @@ export const loginLogs = mysqlTable("login_logs", {
createdAtIdx: index("login_logs_created_at_idx").on(table.createdAt),
}));
// --- 9b. Invitation Codes (注册邀请码audit-P2-3 新增) ---
/**
* 注册邀请码表audit-P2-3
*
* 管理员批量生成,用于:
* - 预分配注册角色student/teacher/parent/admin
* - 可选绑定邮箱(限定指定用户使用)
* - 可选绑定班级(注册后自动加入班级)
* - 可选过期时间
*/
export const invitationCodes = mysqlTable("invitation_codes", {
id: id("id").primaryKey(),
/** 邀请码明文8-12 位大写字母+数字组合unique */
code: varchar("code", { length: 64 }).notNull().unique(),
/** 可选:限定邮箱使用(未设置则任意邮箱可用) */
email: varchar("email", { length: 255 }),
/** 注册后分配的角色student/teacher/parent/admin */
role: varchar("role", { length: 50 }).notNull(),
/** 可选:注册后自动加入的班级 ID */
classId: varchar("class_id", { length: 128 }),
/** 可选:管理员备注 */
notes: varchar("notes", { length: 500 }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
/** 创建者(管理员 userId */
createdBy: varchar("created_by", { length: 128 }),
/** 可选:过期时间(未设置则永久有效) */
expiresAt: timestamp("expires_at", { mode: "date" }),
/** 已使用者 userId未使用为 null */
usedBy: varchar("used_by", { length: 128 }),
/** 使用时间(未使用为 null */
usedAt: timestamp("used_at", { mode: "date" }),
}, (table) => ({
codeIdx: index("invitation_codes_code_idx").on(table.code),
emailIdx: index("invitation_codes_email_idx").on(table.email),
usedByIdx: index("invitation_codes_used_by_idx").on(table.usedBy),
}));
// --- 10. Data Change Logs (数据变更日志) ---
export const dataChangeLogActionEnum = mysqlEnum("action", ["create", "update", "delete"]);
@@ -1027,6 +1086,10 @@ export const messages = mysqlTable("messages", {
receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }),
// V2-P2-13c: 消息星标(接收方可标记重要消息)
isStarred: boolean("is_starred").default(false).notNull(),
// P2-4: 消息撤回(发送方在 2 分钟窗口内可撤回;非空即表示已撤回)
recalledAt: timestamp("recalled_at", { mode: "date" }),
// P2-2: 群组消息 IDfan-out on write教师群发时每条消息共享同一 groupMessageId
groupMessageId: varchar("group_message_id", { length: 128 }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
senderIdx: index("messages_sender_idx").on(table.senderId),
@@ -1036,6 +1099,8 @@ export const messages = mysqlTable("messages", {
receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead),
// V2-P2-13c: 星标索引
receiverStarredIdx: index("messages_receiver_starred_idx").on(table.receiverId, table.isStarred),
// P2-2: 群组消息索引(按 groupMessageId 聚合查询)
groupMessageIdx: index("messages_group_message_idx").on(table.groupMessageId),
}));
// --- 14b. Message Drafts (消息草稿) ---
@@ -1047,11 +1112,73 @@ export const messageDrafts = mysqlTable("message_drafts", {
subject: varchar("subject", { length: 255 }),
content: text("content"),
parentMessageId: varchar("parent_message_id", { length: 128 }),
// P2-10: 跨设备草稿同步 - 设备 ID标识来源设备
deviceId: varchar("device_id", { length: 128 }),
// P2-10: 跨设备草稿同步 - 版本号(乐观锁,冲突检测)
version: int("version").default(1).notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("message_drafts_user_idx").on(table.userId),
userUpdatedIdx: index("message_drafts_user_updated_idx").on(table.userId, table.updatedAt),
// P2-10: 支持按 userId + parentMessageId 查询最新版本草稿
userParentIdx: index("message_drafts_user_parent_idx").on(table.userId, table.parentMessageId),
}));
// --- 14c. Message Templates (快捷回复模板, P2-6) ---
export const messageTemplates = mysqlTable("message_templates", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
title: varchar("title", { length: 100 }).notNull(),
content: text("content").notNull(),
// 排序权重(越小越靠前),默认 0
sortOrder: int("sort_order").default(0).notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("message_templates_user_idx").on(table.userId),
userSortIdx: index("message_templates_user_sort_idx").on(table.userId, table.sortOrder),
}));
// --- 14d. Message Reports (消息举报, P2-5) ---
export const messageReports = mysqlTable("message_reports", {
id: id("id").primaryKey(),
// 举报人
reporterId: varchar("reporter_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
// 被举报消息 ID
messageId: varchar("message_id", { length: 128 }).notNull().references(() => messages.id, { onDelete: "cascade" }),
// 被举报人(冗余,便于按被举报人查询)
reportedUserId: varchar("reported_user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
// 举报理由分类spam / harassment / inappropriate / other
reason: varchar("reason", { length: 50 }).notNull(),
// 详细描述(可选)
description: text("description"),
// 举报状态pending / reviewed / dismissed / actioned
status: varchar("status", { length: 20 }).notNull().default("pending"),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
}, (table) => ({
reporterIdx: index("message_reports_reporter_idx").on(table.reporterId),
messageIdx: index("message_reports_message_idx").on(table.messageId),
reportedUserIdx: index("message_reports_reported_user_idx").on(table.reportedUserId),
statusIdx: index("message_reports_status_idx").on(table.status),
}));
// --- 14e. User Blocks (用户屏蔽, P2-5) ---
export const userBlocks = mysqlTable("user_blocks", {
id: id("id").primaryKey(),
// 屏蔽发起人
blockerId: varchar("blocker_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
// 被屏蔽人
blockedId: varchar("blocked_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
// 同一对 (blocker, blocked) 只能存在一条记录
blockerBlockedUnique: uniqueIndex("user_blocks_blocker_blocked_uniq").on(table.blockerId, table.blockedId),
blockerIdx: index("user_blocks_blocker_idx").on(table.blockerId),
}));
// --- 15. Message Notifications (消息通知) ---
@@ -1059,7 +1186,7 @@ export const messageDrafts = mysqlTable("message_drafts", {
export const messageNotifications = mysqlTable("message_notifications", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 128 }).notNull(), // "message", "announcement", "homework", "grade"
type: varchar("type", { length: 128 }).notNull(), // "message", "announcement", "homework", "grade", "diagnostic"
title: varchar("title", { length: 255 }).notNull(),
content: text("content"),
link: varchar("link", { length: 512 }),
@@ -1157,7 +1284,17 @@ export const parentStudentRelations = mysqlTable("parent_student_relations", {
// --- 18. Attendance (考勤管理) ---
export const attendanceStatusEnum = mysqlEnum("status", ["present", "absent", "late", "early_leave", "excused"]);
export const attendanceStatusEnum = mysqlEnum("status", ["present", "absent", "late", "early_leave", "excused", "school_activity"]);
/**
* L-6 节次考勤:节次枚举。
* - morning_reading: 早读
* - morning: 上午
* - afternoon: 下午
* - evening: 晚自习
* - full_day: 全日默认值向后兼容null 也表示全日)
*/
export const attendancePeriodEnum = mysqlEnum("period", ["morning_reading", "morning", "afternoon", "evening", "full_day"]);
export const attendanceRecords = mysqlTable("attendance_records", {
id: id("id").primaryKey(),
@@ -1166,7 +1303,10 @@ export const attendanceRecords = mysqlTable("attendance_records", {
scheduleId: varchar("schedule_id", { length: 128 }),
date: date("date").notNull(),
status: attendanceStatusEnum.notNull(),
// L-6节次考勤字段。null 表示全日记录(向后兼容已有数据)。
period: attendancePeriodEnum,
remark: text("remark"),
reason: varchar("reason", { length: 255 }),
recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
@@ -1178,6 +1318,8 @@ export const attendanceRecords = mysqlTable("attendance_records", {
studentDateIdx: index("attendance_records_student_date_idx").on(table.studentId, table.date),
scheduleIdx: index("attendance_records_schedule_idx").on(table.scheduleId),
recordedByIdx: index("attendance_records_recorded_by_idx").on(table.recordedBy),
// L-6班级 + 日期 + 节次复合索引,支持按节次查询班级当日点名
classDatePeriodIdx: index("attendance_records_class_date_period_idx").on(table.classId, table.date, table.period),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
@@ -1201,8 +1343,11 @@ export const attendanceRules = mysqlTable("attendance_rules", {
lateThresholdMinutes: int("late_threshold_minutes").default(15),
earlyLeaveThresholdMinutes: int("early_leave_threshold_minutes").default(15),
enableAutoMark: boolean("enable_auto_mark").default(false),
// L-3出勤率阈值预警教师端配置驱动
attendanceRateThreshold: int("attendance_rate_threshold").default(90),
consecutiveAbsenceThreshold: int("consecutive_absence_threshold").default(3),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
updatedAt: timestamp("updated_at").onUpdateNow().notNull(),
}, (table) => ({
classIdx: index("attendance_rules_class_idx").on(table.classId),
classFk: foreignKey({
@@ -1212,6 +1357,93 @@ export const attendanceRules = mysqlTable("attendance_rules", {
}).onDelete("cascade"),
}));
// --- 18b. Leave Requests (L-5 在线请假流程) ---
/**
* 请假类型。
* - sick: 病假
* - personal: 事假
* - family: 家庭事务
* - other: 其他
*/
export const leaveTypeEnum = mysqlEnum("leave_type", [
"sick",
"personal",
"family",
"other",
]);
/**
* 请假审批状态机pending → approved/rejectedapproved/pending 可被 requester 取消为 cancelled。
* - pending: 待审批
* - approved: 已批准(自动同步考勤为 excused
* - rejected: 已拒绝
* - cancelled: 已撤销(申请人在审批前主动撤销)
*/
export const leaveStatusEnum = mysqlEnum("leave_status", [
"pending",
"approved",
"rejected",
"cancelled",
]);
export const leaveRequests = mysqlTable("leave_requests", {
id: id("id").primaryKey(),
/** 请假学生 ID */
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
/** 提交人 ID家长代子女提交时为家长 userId学生本人提交时等于 studentId */
requesterId: varchar("requester_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
/** 学生所属班级 ID提交时快照便于按班级筛选审批 */
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
leaveType: leaveTypeEnum.notNull(),
/** 起始日期(含) */
startDate: date("start_date").notNull(),
/** 结束日期(含) */
endDate: date("end_date").notNull(),
/** 请假事由 */
reason: text("reason").notNull(),
status: leaveStatusEnum.default("pending").notNull(),
/** 审批人 IDpending/cancelled 状态为 null */
reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
/** 审批意见(拒绝时必填,批准时可选) */
reviewComment: text("review_comment"),
reviewedAt: timestamp("reviewed_at"),
/** 是否已同步考勤记录approved 后异步写入 attendance_records置为 true */
attendanceSynced: boolean("attendance_synced").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
studentIdx: index("leave_requests_student_idx").on(table.studentId),
requesterIdx: index("leave_requests_requester_idx").on(table.requesterId),
classIdx: index("leave_requests_class_idx").on(table.classId),
statusIdx: index("leave_requests_status_idx").on(table.status),
reviewerIdx: index("leave_requests_reviewer_idx").on(table.reviewerId),
/** 班级+状态查询索引(教师审批列表常用) */
classStatusIdx: index("leave_requests_class_status_idx").on(table.classId, table.status),
/** 学生+状态查询索引(家长/学生列表常用) */
studentStatusIdx: index("leave_requests_student_status_idx").on(table.studentId, table.status),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "lr_c_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "lr_s_fk",
}).onDelete("cascade"),
requesterFk: foreignKey({
columns: [table.requesterId],
foreignColumns: [users.id],
name: "lr_rq_fk",
}).onDelete("cascade"),
reviewerFk: foreignKey({
columns: [table.reviewerId],
foreignColumns: [users.id],
name: "lr_rv_fk",
}).onDelete("set null"),
}));
// --- 19. Password Security (密码安全策略) ---
export const passwordSecurity = mysqlTable("password_security", {
@@ -1298,6 +1530,8 @@ export const electiveCourses = mysqlTable("elective_courses", {
endDate: date("end_date"),
selectionStartAt: datetime("selection_start_at"),
selectionEndAt: datetime("selection_end_at"),
/** 退课截止时间P2-4 新增):超过此时间学生不可退课;为 null 表示不限制 */
dropDeadline: datetime("drop_deadline"),
status: electiveCourseStatusEnum.default("draft").notNull(),
selectionMode: electiveSelectionModeEnum.default("fcfs").notNull(),
credit: decimal("credit", { precision: 3, scale: 1 }).default("1.0"),
@@ -1319,6 +1553,8 @@ export const courseSelections = mysqlTable("course_selections", {
selectedAt: timestamp("selected_at").defaultNow().notNull(),
enrolledAt: timestamp("enrolled_at"),
droppedAt: timestamp("dropped_at"),
/** 退课理由P2-4 新增):学生退课时可选填写的理由,便于学校运营分析 */
dropReason: varchar("drop_reason", { length: 255 }),
lotteryRank: int("lottery_rank"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
@@ -1374,6 +1610,8 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
// v4-P1-4: 班级报告关联的 classId用于发布时批量通知全班学生
classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "set null" }),
// v4-P2-3: 年级报告关联的 gradeId用于年级级别诊断
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }),
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
period: varchar("period", { length: 50 }),
summary: text("summary"),
@@ -1390,6 +1628,7 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
statusIdx: index("diagnostic_status_idx").on(table.status),
reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType),
classIdx: index("diagnostic_class_idx").on(table.classId),
gradeIdx: index("diagnostic_grade_idx").on(table.gradeId),
}));
// --- 24. Lesson Preparation (备课) ---
@@ -1444,6 +1683,73 @@ export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", {
typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId),
}));
// M2 协同备课Block 级评论表
export const lessonPlanComments = mysqlTable("lesson_plan_comments", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
blockId: varchar("block_id", { length: 128 }).notNull(),
content: text("content").notNull(),
authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id),
resolved: boolean("resolved").default(false).notNull(),
parentCommentId: varchar("parent_comment_id", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
planBlockIdx: index("lpc_plan_block_idx").on(table.planId, table.blockId),
authorIdx: index("lpc_author_idx").on(table.authorId),
}));
// M3 审核工作流:审核记录表
export const lessonPlanReviewRecords = mysqlTable("lesson_plan_review_records", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
reviewerId: varchar("reviewer_id", { length: 128 }).notNull().references(() => users.id),
decision: varchar("decision", { length: 20 }).notNull(), // approved | rejected
comment: text("comment"),
previousStatus: varchar("previous_status", { length: 50 }).notNull(),
newStatus: varchar("new_status", { length: 50 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
planCreatedIdx: index("lprr_plan_created_idx").on(table.planId, table.createdAt),
reviewerIdx: index("lprr_reviewer_idx").on(table.reviewerId),
}));
// M6 资源附件库:附件关联表
export const lessonPlanAttachments = mysqlTable("lesson_plan_attachments", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
blockId: varchar("block_id", { length: 128 }),
fileId: varchar("file_id", { length: 128 }).notNull(),
displayName: varchar("display_name", { length: 255 }).notNull(),
attachmentType: varchar("attachment_type", { length: 50 }).default("reference").notNull(), // reference | material | supplementary
uploadedBy: varchar("uploaded_by", { length: 128 }).notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
planIdx: index("lpa_plan_idx").on(table.planId),
blockIdx: index("lpa_block_idx").on(table.blockId),
fileIdx: index("lpa_file_idx").on(table.fileId),
}));
// M12 代课教师机制:代课教师映射表
export const lessonPlanSubstitutes = mysqlTable("lesson_plan_substitutes", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
originalTeacherId: varchar("original_teacher_id", { length: 128 }).notNull().references(() => users.id),
substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }).notNull().references(() => users.id),
startDate: date("start_date").notNull(),
endDate: date("end_date"),
reason: varchar("reason", { length: 255 }),
status: varchar("status", { length: 20 }).default("active").notNull(), // active | expired | cancelled
createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
planIdx: index("lps_plan_idx").on(table.planId),
originalTeacherIdx: index("lps_original_teacher_idx").on(table.originalTeacherId),
substituteTeacherIdx: index("lps_substitute_teacher_idx").on(table.substituteTeacherId),
dateRangeIdx: index("lps_date_range_idx").on(table.startDate, table.endDate),
}));
// --- 25. System Settings (系统设置 - 键值对存储) ---
export const systemSettings = mysqlTable("system_settings", {
@@ -1563,6 +1869,15 @@ export const gradeDrafts = mysqlTable("grade_drafts", {
content: json("content").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
/**
* P3-6 协同录入锁机制:
* - lockedBy: 当前持有编辑锁的教师 IDnull 表示无锁)
* - lockedAt: 锁获取时间(用于过期判定,默认 5 分钟自动释放)
* - lockToken: 锁定令牌(前端续期时校验,防止伪造他人续期)
*/
lockedBy: varchar("locked_by", { length: 128 }),
lockedAt: timestamp("locked_at"),
lockToken: varchar("lock_token", { length: 64 }),
}, (table) => ({
userDraftIdx: uniqueIndex("gd_user_class_subject_type_idx").on(
table.userId,
@@ -1571,6 +1886,66 @@ export const gradeDrafts = mysqlTable("grade_drafts", {
table.type
),
userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt),
// P3-6: 班级-科目-类型粒度的锁查询索引(用于跨用户协同冲突检测)
classSubjectLockIdx: index("gd_class_subject_lock_idx").on(
table.classId,
table.subjectId,
table.type,
table.lockedBy
),
}));
/**
* P3-7 成绩申诉状态机枚举。
* - pending: 学生已提交申诉,等待教师复核
* - approved: 教师批准申诉(可能调整分数)
* - rejected: 教师驳回申诉(维持原分数)
* - withdrawn: 学生主动撤回申诉
*/
export const gradeAppealStatusEnum = mysqlEnum("appeal_status", [
"pending",
"approved",
"rejected",
"withdrawn",
]);
/**
* 成绩申诉记录 - 学生对成绩提出异议,教师复核后修改并留痕。
*
* P3-7: 满足合规性要求,成绩修改全程留痕。
* 状态机pending → approved/rejected/withdrawn
* 一条 grade_record 只允许有一条 pending 申诉,避免重复申诉。
*/
export const gradeAppeals = mysqlTable("grade_appeals", {
id: id("id").primaryKey(),
gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
/** 申诉快照:申诉时的原始分数(审计留痕,防止后续修改后追溯困难) */
originalScore: decimal("original_score", { precision: 6, scale: 2 }).notNull(),
/** 学生期望的分数(选填,便于教师判断诉求) */
expectedScore: decimal("expected_score", { precision: 6, scale: 2 }),
/** 申诉理由 */
reason: text("reason").notNull(),
status: gradeAppealStatusEnum.default("pending").notNull(),
/** 复核教师 IDpending 状态为 null */
reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
/** 教师复核意见 */
reviewComment: text("review_comment"),
/** 审核后的调整分数approved 且分数有变时填写) */
adjustedScore: decimal("adjusted_score", { precision: 6, scale: 2 }),
reviewedAt: timestamp("reviewed_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
gradeRecordIdx: index("ga_grade_record_idx").on(table.gradeRecordId),
studentIdx: index("ga_student_idx").on(table.studentId),
statusIdx: index("ga_status_idx").on(table.status),
reviewerIdx: index("ga_reviewer_idx").on(table.reviewerId),
/** pending 状态查询索引(应用层校验唯一性,避免多次申诉) */
pendingLookupIdx: index("ga_pending_lookup_idx").on(
table.gradeRecordId,
table.status
),
}));
// --- 28. Adaptive Practice (专项练习闭环) ---
@@ -1669,3 +2044,144 @@ export const practiceAnswers = mysqlTable("practice_answers", {
questionIdx: index("pa_question_idx").on(table.questionId),
sessionOrderIdx: index("pa_session_order_idx").on(table.sessionId, table.orderIndex),
}));
// --- 29. Standards (M1 课标对标体系) ---
/**
* M1 课标库:支持国家标准/课标/自定义三层级。
* 课案通过 lessonPlanStandards 关联表实现多对多关系。
*/
export const standards = mysqlTable("standards", {
id: id("id").primaryKey(),
code: varchar("code", { length: 100 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
/** 层级national国家标准/ curriculum课标/ custom自定义 */
level: varchar("level", { length: 20 }).notNull(),
parentId: varchar("parent_id", { length: 128 }),
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id),
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id),
/** 学段primary / junior_high / senior_high */
stage: varchar("stage", { length: 20 }),
sortOrder: int("sort_order").default(0).notNull(),
isActive: boolean("is_active").default(true).notNull(),
createdBy: varchar("created_by", { length: 128 }).references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
codeIdx: uniqueIndex("std_code_idx").on(table.code),
parentIdx: index("std_parent_idx").on(table.parentId),
subjectGradeIdx: index("std_subject_grade_idx").on(table.subjectId, table.gradeId),
levelIdx: index("std_level_idx").on(table.level),
}));
/** M1 课案 ↔ 课标 多对多关联 */
export const lessonPlanStandards = mysqlTable("lesson_plan_standards", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
standardId: varchar("standard_id", { length: 128 }).notNull().references(() => standards.id, { onDelete: "cascade" }),
/** 关联类型primary主要对标/ related相关对标 */
relationType: varchar("relation_type", { length: 20 }).default("primary").notNull(),
createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
planStandardIdx: uniqueIndex("lps_plan_standard_idx").on(table.planId, table.standardId),
standardIdx: index("lps_standard_idx").on(table.standardId),
}));
// --- 30. Formative Assessment (M5 形成性评价闭环) ---
/**
* M5 互动组件:发布课案时嵌入的 poll/quiz。
* 学生作答后结果回写到 lessonPlanFormativeResponses。
*/
export const lessonPlanFormativeItems = mysqlTable("lesson_plan_formative_items", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
blockId: varchar("block_id", { length: 128 }).notNull(),
/** 互动类型poll / quiz / exit_ticket */
interactionType: varchar("interaction_type", { length: 20 }).notNull(),
/** 题目内容JSON题目/选项/正确答案) */
payload: json("payload").notNull(),
/** 是否启用实时反馈 */
instantFeedback: boolean("instant_feedback").default(false).notNull(),
orderIndex: int("order_index").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
planBlockIdx: index("lpfi_plan_block_idx").on(table.planId, table.blockId),
}));
/** M5 学生作答记录 */
export const lessonPlanFormativeResponses = mysqlTable("lesson_plan_formative_responses", {
id: id("id").primaryKey(),
itemId: varchar("item_id", { length: 128 }).notNull().references(() => lessonPlanFormativeItems.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
classId: varchar("class_id", { length: 128 }),
/** 学生作答JSON */
response: json("response").notNull(),
/** 是否正确quiz 类型适用) */
isCorrect: boolean("is_correct"),
/** 作答耗时(秒) */
durationSec: int("duration_sec"),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
itemIdx: index("lpfr_item_idx").on(table.itemId),
studentIdx: index("lpfr_student_idx").on(table.studentId),
classIdx: index("lpfr_class_idx").on(table.classId),
}));
// --- 31. Lesson Plan Analytics (M10 备课分析仪表盘) ---
/**
* M10 备课分析快照:按日聚合的备课投入/质量统计。
* 用于仪表盘展示"教师备课投入""模板使用率""课标覆盖热力图"。
*/
export const lessonPlanAnalyticsDaily = mysqlTable("lesson_plan_analytics_daily", {
id: id("id").primaryKey(),
snapshotDate: date("snapshot_date").notNull(),
teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id),
subjectId: varchar("subject_id", { length: 128 }),
gradeId: varchar("grade_id", { length: 128 }),
/** 当日新建课案数 */
newPlansCount: int("new_plans_count").default(0).notNull(),
/** 当日编辑时长(分钟) */
editDurationMin: int("edit_duration_min").default(0).notNull(),
/** 当日保存版本数 */
savedVersionsCount: int("saved_versions_count").default(0).notNull(),
/** 当日提交审核数 */
submittedCount: int("submitted_count").default(0).notNull(),
/** 当日发布数 */
publishedCount: int("published_count").default(0).notNull(),
/** 关联的课标数量 */
standardsLinked: int("standards_linked").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
dateTeacherIdx: uniqueIndex("lpad_date_teacher_idx").on(table.snapshotDate, table.teacherId),
dateIdx: index("lpad_date_idx").on(table.snapshotDate),
subjectGradeIdx: index("lpad_subject_grade_idx").on(table.subjectId, table.gradeId),
}));
// --- 32. Lesson Plan AI Evaluation (M8 AI 评估) ---
/**
* M8 AI 课案质量评估记录:保存 AI 对课案的质量评分与建议。
*/
export const lessonPlanAiEvaluations = mysqlTable("lesson_plan_ai_evaluations", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
versionNo: int("version_no").notNull(),
/** 总体评分0-100 */
overallScore: int("overall_score"),
/** 维度评分JSON{ clarity, alignment, engagement, differentiation, assessment } */
dimensionScores: json("dimension_scores"),
/** AI 建议文本 */
suggestions: text("suggestions"),
/** 推荐的课标 ID 列表JSON */
recommendedStandardIds: json("recommended_standard_ids"),
evaluatedBy: varchar("evaluated_by", { length: 128 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
planVersionIdx: index("lpae_plan_version_idx").on(table.planId, table.versionNo),
planIdx: index("lpae_plan_idx").on(table.planId),
}));

View File

@@ -17,7 +17,22 @@
"teacher": ["Help me grade this question", "Generate a classroom activity", "Create a quiz question"],
"student": ["Explain this concept", "Give me a practice question", "Help me study"],
"parent": ["How is my child doing?", "What should I focus on at home?"],
"admin": ["Show AI usage stats", "Which teachers use AI most?"]
"admin": ["Show AI usage stats", "Which teachers use AI most?"],
"context": {
"teacherGrading": ["What are common mistakes in this type of question?", "How should I give constructive feedback?"],
"teacherLesson": ["Suggest a hook for this lesson", "What are some differentiation strategies?"],
"teacherExam": ["Generate a question on this topic", "Analyze the difficulty distribution"],
"studentHomework": ["Give me a hint, not the answer", "Help me understand this concept"]
}
},
"contextMessage": {
"teacherGrading": "Current page: Homework grading view",
"teacherLesson": "Current page: Lesson plan editor",
"teacherExam": "Current page: Exam builder",
"studentErrorBook": "Current page: Error book (student view)",
"studentHomework": "Current page: Student homework view",
"parent": "Current page: Parent dashboard",
"admin": "Current page: Admin dashboard"
}
},
"provider": {
@@ -193,6 +208,7 @@
"contentFailed": "Content generation failed",
"variantFailed": "Question variant generation failed",
"analysisFailed": "Weakness analysis failed",
"statsFailed": "AI usage stats query failed",
"boundaryTitle": "AI Feature Error",
"boundaryDescription": "An error occurred while processing AI request. Please try again.",
"retry": "Retry",
@@ -208,6 +224,10 @@
"similarQuestion": "AI Similar Questions",
"weaknessAnalysis": "AI Weakness Analysis",
"childSummary": "AI Child Summary",
"studyPath": "AI Study Path"
"studyPath": "AI Study Path",
"explainError": "AI Error Explanation"
},
"chart": {
"parseError": "Chart data format error, cannot render"
}
}

View File

@@ -9,7 +9,9 @@
"description": {
"list": "Stay up to date with the latest school announcements.",
"adminList": "Create and manage school-wide announcements.",
"edit": "Update the announcement details."
"edit": "Update the announcement details.",
"detail": "View the announcement details.",
"create": "Create a new announcement."
},
"filter": {
"all": "All",
@@ -75,7 +77,11 @@
"invalidForm": "Invalid form data",
"invalidState": "Invalid state",
"pinToggled": "Pin status updated",
"markedRead": "Marked as read"
"markedRead": "Marked as read",
"invalidFormData": "Invalid form data. Please check and retry.",
"unexpectedError": "An unexpected error occurred. Please try again later.",
"permissionDenied": "You do not have permission to perform this action.",
"forbidden": "You are not allowed to operate on this announcement."
},
"meta": {
"publishedAt": "Published {date}",
@@ -99,5 +105,11 @@
"loadFailed": "Failed to load announcements",
"loadFailedDesc": "Sorry, an unexpected error occurred while loading announcements. Please try again later.",
"retry": "Retry"
},
"pagination": {
"label": "Announcement pagination",
"prev": "Previous page",
"next": "Next page",
"page": "Page {page}"
}
}

View File

@@ -20,7 +20,17 @@
"absent": "Absent",
"late": "Late",
"early_leave": "Early Leave",
"excused": "Excused"
"excused": "Excused",
"school_activity": "School Activity"
},
"period": {
"label": "Period",
"morning_reading": "Morning Reading",
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening Self-Study",
"full_day": "Full Day",
"allPeriods": "All Periods"
},
"stats": {
"totalRecords": "Total Records",
@@ -29,6 +39,7 @@
"late": "Late",
"earlyLeave": "Early Leave",
"excused": "Excused",
"schoolActivity": "School Activity",
"attendanceRate": "Attendance Rate",
"lateRate": "Late Rate",
"recentRecords": "Recent Records",
@@ -63,6 +74,7 @@
"class": "Class",
"date": "Date",
"status": "Status",
"reason": "Reason",
"remark": "Remark",
"recorder": "Recorder",
"createdAt": "Created At"
@@ -77,15 +89,122 @@
"confirmClassSwitch": "Switching class will discard unsaved changes. Continue?",
"confirmClassSwitchAction": "Switch Class",
"saved": "Attendance saved",
"saving": "Saving...",
"updated": "Attendance updated",
"deleted": "Attendance record deleted"
"deleted": "Attendance record deleted",
"reasonPlaceholder": "Enter reason"
},
"rules": {
"lateThreshold": "Late Threshold (minutes)",
"earlyLeaveThreshold": "Early Leave Threshold (minutes)",
"enableAutoMark": "Enable Auto Mark",
"attendanceRateThreshold": "Attendance Rate Threshold (%)",
"attendanceRateThresholdHint": "Students below this value will trigger a warning",
"consecutiveAbsenceThreshold": "Consecutive Absence Threshold (count)",
"consecutiveAbsenceThresholdHint": "Students reaching this count of consecutive absences will trigger a warning",
"saved": "Attendance rules saved"
},
"warnings": {
"title": "Attendance Warnings",
"noWarnings": "No warnings",
"noWarningsDescription": "All students in this class have normal attendance.",
"thresholdSummary": "Rate threshold {rate}% / Consecutive absence threshold {absence}",
"dates": "Related dates",
"types": {
"low_attendance_rate": "Attendance rate {current}% below threshold {threshold}%",
"consecutive_absence": "{current} consecutive absences, reached threshold {threshold}"
},
"severity": {
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"trend": {
"title": "Attendance Trend",
"description": "{start} to {end}",
"noData": "No trend data",
"noDataDescription": "No attendance records in the selected time range for this class.",
"granularity": {
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly"
},
"series": {
"presentRate": "Present Rate",
"lateRate": "Late Rate",
"absentRate": "Absent Rate"
}
},
"comparison": {
"title": "Class Comparison",
"description": "{grade} · {start} to {end} · Grade average {avg}%",
"noData": "No comparison data",
"noDataDescription": "Select a grade to view class attendance comparison.",
"aboveAverage": "Above average",
"average": "Average",
"belowAverage": "Below average",
"series": {
"presentRate": "Present Rate"
},
"columns": {
"class": "Class",
"total": "Total",
"presentRate": "Present Rate",
"lateRate": "Late Rate",
"absentRate": "Absent Rate",
"badge": "Evaluation"
}
},
"report": {
"title": "Attendance Report",
"description": "Generate class attendance weekly/monthly reports, printable as PDF.",
"controls": "Report Settings",
"type": "Report Type",
"types": {
"weekly": "Weekly",
"monthly": "Monthly"
},
"startDate": "Start Date",
"endDate": "End Date",
"print": "Print / Save PDF",
"printing": "Preparing print...",
"period": "Report Period",
"generatedAt": "Generated At",
"summary": "Summary",
"studentDetails": "Student Details",
"parentSignature": "Parent Signature",
"parentSignatureNotice": "Parents please confirm you have reviewed your child's attendance, sign and return to the homeroom teacher.",
"parentName": "Parent Name",
"signature": "Signature",
"relationship": "Relationship",
"signDate": "Sign Date",
"parentComment": "Parent Comments",
"footer": "This report is auto-generated. Please contact the homeroom teacher for any questions.",
"noData": "No report data",
"noDataDescription": "No attendance records in the selected time range for this class."
},
"correlation": {
"title": "Attendance-Grade Correlation Analysis",
"description": "{className} · {start} to {end} · correlation between attendance rate and average score",
"noData": "No correlation data",
"noDataDescription": "No attendance or grade records in the selected time range for this class.",
"noStudents": "No student data",
"noStudentsDescription": "No students with both attendance and grade records in the selected time range.",
"correlationCoefficient": "Correlation r",
"notAvailable": "Insufficient data",
"scatterTitle": "Attendance-Score Scatter Plot",
"scatterSeries": "Students",
"studentDetails": "Student Details",
"riskLevelLabel": "Risk Level",
"attendanceRate": "Attendance Rate",
"averageScore": "Average Score",
"absentCount": "Absence Count",
"gradeRecordCount": "Grade Records",
"riskCounts": { "high": "High-Risk Students", "medium": "Medium-Risk Students", "low": "Low-Risk Students" },
"riskLevel": { "high": "High Risk", "medium": "Medium Risk", "low": "Low Risk" },
"interpretation": { "strong_negative": "Strong negative: lower attendance correlates with lower scores", "weak_negative": "Weak negative: some negative correlation between attendance and scores", "negligible": "No significant linear correlation", "weak_positive": "Weak positive: some positive correlation between attendance and scores", "strong_positive": "Strong positive: higher attendance correlates with higher scores", "insufficient_data": "Insufficient data to compute correlation" }
},
"errors": {
"notFound": "Attendance record not found",
"noOwnership": "You do not own this attendance record",
@@ -94,7 +213,10 @@
"invalidForm": "Invalid form data",
"missingRecords": "Missing records data",
"invalidRecordsJson": "Invalid attendance data format",
"unexpected": "Unexpected error"
"unexpected": "Unexpected error",
"invalid_date": "Invalid date format",
"invalid_startDate": "Invalid start date format",
"invalid_endDate": "Invalid end date format"
},
"messages": {
"recorded": "Attendance recorded",

View File

@@ -1,6 +1,86 @@
{
"title": "Audit Logs",
"description": "Track all user operations system-wide for security and compliance.",
"table": {
"user": "User",
"module": "Module",
"action": "Action",
"target": "Target",
"status": "Status",
"ipAddress": "IP Address",
"time": "Time",
"userAgent": "User Agent",
"tableName": "Table",
"recordId": "Record ID",
"changedBy": "Changed By",
"view": "View",
"hide": "Hide",
"oldValue": "Old Value",
"newValue": "New Value"
},
"filter": {
"anyModule": "Any Module",
"anyStatus": "Any Status",
"anyAction": "Any Action",
"anyTable": "Any Table",
"actionPlaceholder": "Action...",
"userIdPlaceholder": "User ID...",
"modulePlaceholder": "Module",
"statusPlaceholder": "Status",
"tablePlaceholder": "Table",
"actionSelectPlaceholder": "Action",
"startDate": "Start Date",
"endDate": "End Date",
"success": "Success",
"failure": "Failure",
"create": "Create",
"update": "Update",
"delete": "Delete",
"signIn": "Sign In",
"signOut": "Sign Out",
"signUp": "Sign Up",
"reset": "Reset"
},
"empty": {
"audit": "No audit logs found.",
"login": "No login logs found.",
"dataChange": "No data change logs found."
},
"export": {
"button": "Export Excel",
"success": "Export ready",
"failed": "Export failed"
},
"error": {
"title": "Load Failed",
"description": "An error occurred while loading data. Please try again later.",
"retry": "Retry",
"boundaryTitle": "Audit Data Load Failed",
"boundaryDescription": "An error occurred while loading this audit data section. Please retry."
},
"detail": {
"title": "Log Detail",
"description": "View the complete field information of the log entry.",
"userId": "User ID",
"userName": "Username",
"action": "Action",
"module": "Module",
"targetId": "Target ID",
"targetType": "Target Type",
"detail": "Detail",
"ipAddress": "IP Address",
"userAgent": "User Agent",
"status": "Status",
"createdAt": "Time",
"errorMessage": "Error Message",
"tableName": "Table",
"recordId": "Record ID",
"changedBy": "Changed By",
"oldValue": "Old Value",
"newValue": "New Value",
"close": "Close",
"viewDetail": "View Detail"
},
"loginLogs": {
"title": "Login Logs",
"description": "Monitor all authentication events including sign-in, sign-out, and sign-up."
@@ -8,5 +88,50 @@
"dataChanges": {
"title": "Data Change Logs",
"description": "Track all data changes (create, update, delete) for compliance."
},
"overview": {
"title": "Audit Overview",
"description": "Real-time visibility into system activity, security events, and data changes.",
"stats": {
"auditEventsToday": "Audit Events Today",
"failedLoginsToday": "Failed Logins Today",
"dataChangesToday": "Data Changes Today",
"totalAuditLogs": "Total Audit Logs"
},
"trendChart": {
"title": "Activity Trend (Last 7 Days)",
"description": "Daily counts of audit events, login events, and data changes.",
"auditEvents": "Audit Events",
"loginEvents": "Login Events",
"dataChanges": "Data Changes"
},
"distributionChart": {
"title": "Data Changes by Action",
"description": "Distribution of create, update, and delete operations.",
"empty": "No data change records yet."
},
"quickLinks": {
"title": "Quick Links",
"auditLogs": "View Audit Logs",
"loginLogs": "View Login Logs",
"dataChanges": "View Data Changes"
}
},
"retention": {
"title": "Data Retention Policy",
"description": "Configure how long audit logs are kept before automatic cleanup.",
"retentionDays": "Audit Log Retention (days)",
"retentionDaysDescription": "Audit logs and data change logs older than this will be deleted. Min 30, max 3650 days.",
"loginLogRetentionDays": "Login Log Retention (days)",
"loginLogRetentionDaysDescription": "Retention period for login logs (including login success/failure/signup/signout). Recommended to be longer than audit logs for security forensics. Min 30, max 3650 days.",
"autoCleanupEnabled": "Enable Auto Cleanup",
"autoCleanupEnabledDescription": "When enabled, expired logs are deleted daily via cron job.",
"save": "Save Configuration",
"purge": "Purge Now",
"purgeConfirm": "This will immediately delete all logs older than the configured retention period. Continue?",
"purgeSuccess": "Purge complete: {auditLogsDeleted} audit logs, {loginLogsDeleted} login logs, {dataChangeLogsDeleted} data change logs deleted.",
"saveSuccess": "Retention configuration saved.",
"saveFailed": "Failed to save configuration.",
"purgeFailed": "Failed to purge logs."
}
}
}

View File

@@ -2,14 +2,31 @@
"login": {
"title": "Sign In",
"subtitle": "Sign in to your Next_Edu account",
"subtitle2fa": "Enter the 6-digit code from your authenticator app",
"email": "Email",
"password": "Password",
"rememberMe": "Remember me",
"signIn": "Sign In",
"verifyAndSignIn": "Verify & Sign In",
"signingIn": "Signing in...",
"forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"register": "Register"
"register": "Register",
"totpLabel": "2FA Code",
"totpHint": "Enter your 6-digit authenticator code or an 8-character backup code.",
"backToLogin": "Back to login",
"orContinueWith": "Or continue with",
"errors": {
"invalidCredentials": "Invalid email or password",
"invalid2fa": "Invalid 2FA code. Please try again.",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password",
"emailFormat": "Invalid email format",
"totpRequired": "Please enter the 2FA code",
"tooManyAttempts": "Too many failed attempts. The account may be locked. Please try again later or contact your administrator.",
"accountLockedHint": "Multiple failed sign-in attempts detected. The account may be temporarily locked. Try again later or use \"Forgot password\" to reset."
},
"attemptsRemaining": "Attempts remaining: {count}"
},
"register": {
"title": "Register",
@@ -21,7 +38,51 @@
"signUp": "Sign Up",
"signingUp": "Signing up...",
"hasAccount": "Already have an account?",
"signIn": "Sign In"
"signIn": "Sign In",
"createAccount": "Create Account",
"subtitleAlt": "Fill in the information below to create your account",
"birthDate": "Date of Birth",
"currentAge": "Current age: {age} years old",
"guardianSectionTitle": "You are a minor. Please provide guardian information.",
"guardianName": "Guardian Name",
"guardianNamePlaceholder": "Enter guardian name",
"guardianPhone": "Guardian Phone",
"guardianPhonePlaceholder": "Enter guardian phone number",
"guardianRelation": "Relationship to Guardian",
"guardianRelationPlaceholder": "Select relationship",
"guardianRelationFather": "Father",
"guardianRelationMother": "Mother",
"guardianRelationOther": "Other Legal Guardian",
"namePlaceholder": "Enter your name",
"agreeTermsLabel": "I have read and agree to the Privacy Policy and Terms of Service",
"agreeTermsRich": "I have read and agree to the <privacy>Privacy Policy</privacy> and the <terms>Terms of Service</terms>",
"agreeTermsLinkPrivacy": "Privacy Policy",
"agreeTermsLinkTerms": "Terms of Service",
"agreeTermsAndLabel": "and",
"agreeGuardianLabel": "I confirm that I have obtained guardian consent to use this service",
"loginNow": "Sign In Now",
"alreadyHaveAccount": "Already have an account?",
"toast": {
"needAgreeTerms": "Please read and agree to the Privacy Policy and Terms of Service before registering",
"needGuardianConsent": "Minors must confirm guardian consent before registering",
"needGuardianRelation": "Please select your relationship with the guardian",
"createSuccess": "Account created successfully",
"createFailed": "Registration failed"
},
"errors": {
"RATE_LIMIT_EXCEEDED": "Too many registration attempts. Please try again later.",
"VALIDATION_FAILED": "Please check that the input is correct",
"EMAIL_TAKEN": "This email is already registered",
"BREACHED_PASSWORD": "This password has appeared in a known data breach. Please choose a different password.",
"INVITATION_CODE_INVALID": "Invitation code is invalid or already used",
"INVITATION_CODE_USED": "Invitation code has already been used",
"DEFAULT_ROLE_MISSING": "System initialization error. Please contact the administrator.",
"UNKNOWN": "Account creation failed. Please try again later."
},
"emailFormatError": "Invalid email format",
"invitationCodeLabel": "Invitation Code (optional)",
"invitationCodePlaceholder": "Enter invitation code if you have one",
"invitationCodeMax": "Invitation code cannot exceed 64 characters"
},
"errors": {
"invalidCredentials": "Invalid email or password",

View File

@@ -51,5 +51,414 @@
"homeroomTeacher": "Homeroom Teacher",
"studentCount": "Student Count",
"actions": "Actions"
},
"list": {
"title": "Class Management",
"description": "Manage classes and assign teachers.",
"new": "New Class",
"empty": {
"title": "No classes",
"description": "Create your first class to get started.",
"noMatch": "No matches found",
"noMatchDescription": "Try adjusting filters or clearing search."
},
"column": {
"school": "School",
"grade": "Grade",
"name": "Class Name",
"homeroom": "Homeroom",
"room": "Room",
"subjectTeachers": "Subject Teachers",
"studentCount": "Students",
"invitationCode": "Invitation Code",
"updated": "Updated",
"actions": "Actions"
},
"actions": {
"edit": "Edit",
"delete": "Delete",
"viewDetail": "View Details"
},
"failedCreate": "Failed to create class",
"failedUpdate": "Failed to update class",
"failedDelete": "Failed to delete class",
"failedStatus": "Failed to update status",
"failedRegenerate": "Failed to regenerate invitation code",
"failedCopy": "Failed to copy"
},
"form": {
"createTitle": "New Class",
"editTitle": "Edit Class",
"name": "Class Name",
"namePlaceholder": "e.g., Grade 1 Class 1",
"school": "School",
"schoolPlaceholder": "Select a school",
"grade": "Grade",
"gradePlaceholder": "Select a grade",
"homeroom": "Homeroom Teacher",
"homeroomPlaceholder": "Select a teacher",
"room": "Room",
"roomPlaceholder": "Optional",
"subjectTeachers": "Subject Teachers",
"subjectTeacherPlaceholder": "Select a teacher",
"noSchools": "No schools",
"noGrades": "No grades",
"noTeachers": "No teachers",
"selectSchool": "Select a school",
"selectGrade": "Select a grade",
"selectTeacher": "Select a teacher",
"optional": "Optional",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"saveChanges": "Save Changes",
"saving": "Saving...",
"creating": "Creating...",
"deleting": "Deleting...",
"adding": "Adding...",
"add": "Add"
},
"delete": {
"title": "Delete Class",
"description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
},
"filters": {
"search": "Search classes...",
"school": "School",
"allSchools": "All Schools",
"grade": "Grade",
"allGrades": "All Grades",
"class": "Class",
"allClasses": "All Classes",
"status": "Status",
"allStatuses": "All Statuses",
"weekday": "Weekday",
"selectWeekday": "Select weekday",
"allWeekdays": "All",
"selectClass": "Select a class",
"selectSchool": "Select a school",
"selectGrade": "Select a grade",
"clear": "Clear filters",
"reset": "Reset",
"unknownClass": "Unknown Class",
"filterByClass": "Filter by Class",
"filterByStatus": "Filter by Status",
"addEvent": "Add Event",
"addStudent": "Add student",
"email": "Email",
"emailPlaceholder": "student@example.com",
"subjectPlaceholderExample": "e.g. Math",
"createScheduleEntryDescription": "Create a class schedule entry.",
"enrollStudentDescription": "Enroll a student by email to a class.",
"startTime": "Start",
"endTime": "End"
},
"myClasses": {
"title": "My Classes",
"joinNew": "Join New Class",
"joinTitle": "Join a Class",
"joinDescription": "Enter the 6-digit invitation code provided by your administrator.",
"invitationCode": "Invitation Code",
"invitationCodePlaceholder": "e.g. 123456",
"invitationCodeHint": "Ask your administrator for the code if you don't have one.",
"subject": "Subject",
"subjectPlaceholder": "Select a subject",
"noSubjects": "No subjects available",
"selectSubject": "Select a subject",
"join": "Join Class",
"joining": "Joining...",
"cancel": "Cancel",
"empty": {
"title": "No classes",
"description": "Join a class or wait for an administrator to assign one."
},
"entryPass": "Entry Pass",
"submissionTrends": "Submission Trends",
"students": "Students",
"room": "Room",
"noRoom": "No Room",
"vsLastWeek": "vs last week",
"viewDetail": "View Details",
"copyCode": "Copy invitation code",
"regenerateCode": "Regenerate",
"codeCopied": "Invitation code copied"
},
"students": {
"title": "Student List",
"searchPlaceholder": "Search students...",
"empty": {
"title": "No students",
"description": "There are no students in this class yet.",
"noMatch": "No students match your filters",
"noMatchDescription": "Try clearing filters or adjusting keywords."
},
"column": {
"name": "Name",
"email": "Email",
"status": "Status",
"scores": "Scores",
"actions": "Actions"
},
"status": {
"active": "Active",
"inactive": "Inactive"
},
"actions": {
"setActive": "Set Active",
"setInactive": "Set Inactive",
"remove": "Remove from Class"
},
"removeTitle": "Remove from Class",
"removeDescription": "Are you sure you want to remove \"{name}\" from the class?"
},
"schedule": {
"title": "Class Schedule",
"empty": {
"title": "No schedule",
"description": "The schedule for this class has not been set up yet.",
"noMatch": "No schedule for this class",
"noMatchDescription": "Try selecting another class."
},
"column": {
"weekday": "Weekday",
"period": "Period",
"subject": "Subject",
"location": "Location",
"teacher": "Teacher",
"actions": "Actions"
},
"form": {
"createTitle": "New Schedule Item",
"editTitle": "Edit Schedule Item",
"createDescription": "Create a class schedule entry.",
"editDescription": "Update class schedule entry.",
"weekday": "Weekday",
"period": "Period",
"subject": "Subject",
"location": "Location",
"locationPlaceholder": "Optional",
"subjectPlaceholder": "e.g. Math",
"startLabel": "Start",
"endLabel": "End",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"deleteTitle": "Are you sure?",
"deleteDescription": "This will permanently delete this schedule item."
},
"weekday": {
"1": "Mon",
"2": "Tue",
"3": "Wed",
"4": "Thu",
"5": "Fri",
"6": "Sat",
"7": "Sun"
}
},
"detail": {
"header": {
"students": "{count} Students",
"homeroom": "Homeroom {name}",
"room": "Room {room}",
"invite": "Invite",
"editDetails": "Edit details",
"inviteStudents": "Invite students",
"classSettings": "Class settings",
"moreActions": "More actions"
},
"overview": {
"averageScore": "Average Score",
"submissionRate": "Submission Rate",
"papersToGrade": "To Grade",
"overdueCount": "Overdue",
"overallPerformance": "Overall performance",
"averageTurnInRate": "Average turn-in rate",
"pendingReviews": "Pending reviews",
"activeAssignmentsPastDue": "Active assignments past due"
},
"trends": {
"submitted": "Submitted",
"totalStudents": "Total Students",
"averageScore": "Average Score",
"medianScore": "Median Score",
"latest": "Latest",
"submission": "Submission",
"score": "Score",
"allSubjects": "All Subjects",
"scoreTrends": "Score Trends",
"recentTurnInRates": "Recent assignment turn-in rates",
"avgVsMedian": "Average vs Median performance",
"noDataForSubject": "No data for this subject"
},
"assignments": {
"due": "Due {date}",
"noDueDate": "No due date",
"viewAll": "View All",
"createHomework": "Create Homework",
"activeCount": "{count} active assignments",
"submittedCount": "{submitted}/{total} Submitted",
"avgLabel": "Avg"
},
"widgets": {
"recentHomework": "Recent Homework",
"weeklySchedule": "Weekly Schedule",
"quickActions": "Quick Actions",
"submissionTrends": "Submission Trends",
"studentList": "Student List"
},
"edit": {
"title": "Edit Class",
"name": "Class Name",
"homeroom": "Homeroom Teacher",
"room": "Room",
"cancel": "Cancel",
"save": "Save"
},
"empty": {
"noSchedule": "No schedule",
"noScheduleDescription": "The schedule for this class has not been set up yet.",
"noAssignments": "No assignments",
"noAssignmentsDescription": "No assignments have been created for this class yet."
},
"students": {
"viewAll": "View All",
"activeCount": "{count} active students"
},
"schedule": {
"manage": "Manage",
"showingWeekdays": "Showing Mon-Fri schedule"
},
"quickActions": {
"manageSchedule": "Manage Schedule",
"messageClassComingSoon": "Message Class (Coming soon)"
}
},
"grade": {
"title": "Class Management",
"description": "Manage classes in your grade.",
"insights": {
"title": "Grade Insights",
"description": "View homework statistics for your grade.",
"filters": "Filters",
"grade": "Grade",
"apply": "Apply",
"selectGrade": "Select Grade",
"noGrades": "No managed grades",
"noGradesDescription": "You have not been assigned as a grade head or teaching head.",
"selectToView": "Select a grade to view insights",
"selectToViewDescription": "Select a grade to view latest homework and historical score statistics.",
"notFound": "Grade not found",
"notFoundDescription": "The grade may not exist or has no accessible data.",
"noData": "No homework data for this grade",
"noDataDescription": "No assignments have been given to students in this grade yet.",
"classes": "Classes",
"students": "Students",
"overallAvg": "Overall Avg",
"latestAvg": "Latest Avg",
"homeworkTimeline": "Homework Timeline",
"classRanking": "Class Ranking",
"assignment": "Assignment",
"status": "Status",
"created": "Created",
"targeted": "Targeted",
"submitted": "Submitted",
"graded": "Graded",
"avg": "Avg",
"median": "Median",
"class": "Class",
"latestAvgCol": "Latest Avg",
"prevAvg": "Prev Avg",
"delta": "Delta",
"overallAvgCol": "Overall Avg",
"active": "Active",
"inactive": "Inactive"
}
},
"bulk": {
"importStudents": {
"title": "Bulk Import Students",
"description": "One email per line, format: name,email or just email",
"placeholder": "John,john@example.com\njane@example.com",
"submit": "Import",
"success": "Successfully imported {imported} students, {failed} failed",
"failed": "Bulk import failed"
},
"assignTeachers": {
"title": "Bulk Assign Teachers",
"description": "Format: class name,subject,teacher email",
"placeholder": "Class 1,Math,john@example.com\nClass 2,Science,jane@example.com",
"submit": "Assign",
"success": "Successfully updated {updated} assignments, {failed} failed",
"failed": "Bulk assignment failed"
}
},
"errors": {
"boundary": {
"title": "Loading failed",
"description": "An error occurred while loading data. Please retry.",
"retry": "Retry"
}
},
"error": {
"title": "Loading failed",
"description": "An error occurred while loading data. Please retry.",
"retry": "Retry"
},
"actions": {
"classCreated": "Class created successfully",
"classUpdated": "Class updated successfully",
"classDeleted": "Class deleted successfully",
"studentAdded": "Student added successfully",
"studentUpdated": "Student updated successfully",
"invitationCodeReady": "Invitation code ready",
"invitationCodeUpdated": "Invitation code updated",
"invitationCodeGenerated": "Invitation code generated",
"invitationCodeRevoked": "Invitation code revoked",
"scheduleItemCreated": "Schedule item created successfully",
"scheduleItemUpdated": "Schedule item updated successfully",
"scheduleItemDeleted": "Schedule item deleted successfully",
"joinedClass": "Joined class successfully",
"bulkImportResult": "Imported {imported} students, {failed} failed",
"bulkAssignResult": "Updated {updated} assignments, {failed} failed",
"missingClassId": "Missing class id",
"missingScheduleId": "Missing schedule id",
"missingCodeId": "Missing code id",
"missingEnrollmentInfo": "Missing enrollment info",
"invalidScheduleData": "Invalid schedule item data",
"invalidExpiresInHours": "Invalid expiresInHours",
"invalidMaxUses": "Invalid maxUses",
"csvRequired": "CSV data is required",
"noValidEntries": "No valid entries found",
"classNameGradeTeacherRequired": "Class name, grade and teacher are required",
"classNameGradeRequired": "Class name and grade are required",
"invitationCodeRequired": "Invitation code is required",
"subjectRequired": "Subject is required",
"tooManyAttempts": "Too many attempts, please try again later",
"scheduleItemNotFound": "Schedule item not found",
"classNotFoundOrNotLinked": "Class not found or not linked to a grade",
"notPermissionCreateGrade": "You do not have permission to create classes for this grade",
"notPermissionUpdateClass": "You do not have permission to update this class",
"notPermissionMoveClass": "You do not have permission to move class to this grade",
"notPermissionDeleteClass": "You do not have permission to delete this class",
"notOwnClass": "You do not own this class",
"notPermissionManageClass": "You do not have permission to manage this class",
"notPermissionManageClassSchedule": "You do not have permission to manage this class schedule",
"onlyAdminsAndGradeHeads": "Only admins and grade heads can create classes",
"classAndEmailRequired": "Please select a class and provide student email",
"invalidWeekday": "Invalid weekday",
"invalidSubjectTeachers": "Invalid subject teachers"
},
"metadata": {
"myClasses": "My Classes",
"classDetail": "Class Detail",
"schedule": "Class Schedule",
"students": "Students",
"studentLearning": "Learning Center",
"studentCourses": "My Courses",
"studentClassDetail": "Class Home",
"studentSchedule": "My Schedule"
}
}

View File

@@ -67,5 +67,20 @@
},
"breadcrumb": {
"home": "Home"
},
"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"
},
"error": {
"boundaryTitle": "Section Error",
"boundaryDescription": "An error occurred while processing the request. Please retry.",
"retry": "Retry"
}
}

View File

@@ -12,5 +12,223 @@
"create": {
"title": "Create Course Plan",
"description": "Create a new course teaching plan."
},
"status": {
"planning": "Planning",
"active": "Active",
"completed": "Completed",
"paused": "Paused"
},
"filter": {
"all": "All",
"placeholder": "Filter by status"
},
"list": {
"new": "New Course Plan",
"empty": "There are no course plans yet.",
"emptyFiltered": "No course plans match the current filter.",
"unknownSubject": "Unknown Subject",
"noClass": "No class",
"semester": "Semester {semester}",
"created": "Created {date}",
"teacher": "Teacher: {name}",
"unassigned": "Unassigned",
"emptyCta": "Create your first course plan"
},
"detail": {
"back": "Back to course plans",
"heading": "Course Plan",
"edit": "Edit",
"delete": "Delete",
"noClass": "No class",
"unknownSubject": "Unknown Subject",
"unknownSubjectHeading": "Course Plan",
"semester": "Semester {semester}",
"teacher": "Teacher: {name}",
"unassigned": "Unassigned",
"created": "Created {date}",
"startDate": "Start {date}",
"endDate": "End {date}",
"syllabus": "Syllabus",
"objectives": "Objectives",
"weekPlans": "Week Plans",
"addWeekPlan": "Add Week Plan",
"emptyWeekPlans": "No week plans yet.",
"emptyWeekPlansCta": "Click \"Add Week Plan\" to create the first one.",
"week": "Week",
"topic": "Topic",
"hours": "Hours",
"chapter": "Chapter",
"statusCol": "Status",
"completed": "Completed",
"pending": "Pending",
"notes": "Notes: {notes}",
"dragHandle": "Drag handle (hold to reorder)",
"selectWeekAria": "Select week {week}",
"reorderFailed": "Failed to reorder, please retry",
"reorderSaved": "Order saved",
"viewTextbookAria": "View textbook chapter: {chapter}",
"viewHomeworkAria": "View related homework",
"deleteTitle": "Delete Course Plan",
"deleteDescription": "This will permanently delete the course plan and all its week plans."
},
"form": {
"new": "New Course Plan",
"edit": "Edit Course Plan",
"class": "Class",
"selectClass": "Select a class",
"subject": "Subject",
"selectSubject": "Select a subject",
"teacher": "Teacher",
"selectTeacher": "Select a teacher",
"academicYear": "Academic Year",
"optional": "Optional",
"semester": "Semester",
"semester1": "Semester 1",
"semester2": "Semester 2",
"status": "Status",
"selectStatus": "Select status",
"totalHours": "Total Hours",
"weeklyHours": "Weekly Hours",
"startDate": "Start Date",
"endDate": "End Date",
"syllabus": "Syllabus",
"syllabusPlaceholder": "Teaching syllabus...",
"objectives": "Objectives",
"objectivesPlaceholder": "Teaching objectives...",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"saving": "Saving...",
"invalidState": "Invalid form state",
"saveFailed": "Failed to save course plan"
},
"item": {
"addTitle": "Add Week Plan",
"editTitle": "Edit Week Plan",
"week": "Week",
"hours": "Hours",
"topic": "Topic",
"topicPlaceholder": "Week topic",
"content": "Content",
"contentPlaceholder": "Teaching content...",
"chapter": "Textbook Chapter",
"chapterPlaceholder": "e.g. Chapter 3",
"completedAt": "Completed At",
"notes": "Notes",
"notesPlaceholder": "Notes...",
"markComplete": "Mark Complete",
"markIncomplete": "Mark Incomplete",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"invalidState": "Invalid form state",
"saveFailed": "Failed to save week plan",
"deleteFailed": "Failed to delete",
"updateFailed": "Failed to update"
},
"progress": {
"label": "Progress",
"hours": "{completed} / {total} hours ({percent}%)",
"weekPlansCompleted": "{completed} of {total} week plans completed"
},
"toast": {
"created": "Course plan created",
"updated": "Course plan updated",
"deleted": "Course plan deleted",
"weekAdded": "Week plan added",
"weekUpdated": "Week plan updated",
"weekDeleted": "Week plan deleted",
"markedComplete": "Marked as completed",
"markedIncomplete": "Marked as incomplete",
"deleteFailed": "Delete failed",
"saveFailed": "Save failed",
"reorderSaved": "Order saved",
"reorderFailed": "Failed to save order",
"bulkMarked": "Marked {count} items as completed",
"bulkFailed": "Bulk operation failed",
"copySuccess": "Course plan copied to {count} classes",
"copyFailed": "Copy failed",
"exportFailed": "Export failed",
"invalidGradeId": "Invalid grade id"
},
"teacher": {
"title": "My Course Plans",
"description": "View your course teaching plans and weekly schedules."
},
"parent": {
"title": "Child's Course Plans",
"description": "View course teaching plans for your child's class."
},
"student": {
"title": "Course Plans",
"description": "View your class course plans and weekly schedules."
},
"errors": {
"notFound": "Course plan not found",
"invalidForm": "Invalid form data",
"invalidGradeId": "Invalid grade id",
"noPermission": "No permission to access this course plan",
"loadFailed": "Failed to load course plans",
"loadFailedDesc": "An error occurred while loading course plan data. Please retry later.",
"retry": "Retry"
},
"error": {
"boundaryTitle": "Course plan section failed to load",
"boundaryDescription": "An error occurred while loading course plan data. Please retry.",
"retry": "Retry"
},
"calendar": {
"title": "Calendar View",
"list": "List View",
"noPlans": "No teaching arrangements in this period",
"prevMonth": "Previous Month",
"nextMonth": "Next Month",
"today": "Today",
"monthTitle": "{month} {year}",
"weekShort": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"weekFull": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
"week": "Week {week}",
"hours": "{hours} hours",
"completed": "Completed",
"pending": "Pending",
"noStartDate": "The course plan has no start date; calendar view unavailable."
},
"export": {
"title": "Export",
"csv": "Export CSV",
"pdf": "Export PDF",
"excel": "Export Excel",
"exported": "Course plan exported",
"exportFailed": "Export failed",
"filename": "course-plan-{subject}-{className}",
"content": "Content",
"notes": "Notes"
},
"bulk": {
"markComplete": "Bulk Mark Complete",
"copy": "Copy to Other Classes",
"copyTitle": "Select Target Classes",
"confirm": "Confirm Copy",
"selectTargets": "Select classes to copy to"
},
"templates": {
"title": "Course Plan Templates",
"empty": "No templates available",
"createFromTemplate": "Create from template",
"saveAsTemplate": "Save as template",
"searchPlaceholder": "Search class / subject / teacher",
"confirm": "Confirm create",
"cancel": "Cancel",
"cloning": "Creating...",
"cloneSuccess": "Course plan created from template",
"cloneFailed": "Failed to create from template",
"selectTemplateFirst": "Please select a template first",
"noTargetClass": "Please select a target class first"
},
"loading": {
"title": "Loading...",
"description": "Loading course plan data..."
}
}
}

View File

@@ -90,6 +90,8 @@
"empty": "No to-do items today"
},
"sections": {
"quickStats": "Quick Stats",
"trends": "Trends",
"userGrowthTrend": "User Growth Trend (Last 30 Days)",
"homeworkSubmissionTrend": "Homework Submission Trend (Last 7 Days)",
"userRoles": "User Roles",
@@ -106,7 +108,9 @@
"homework": "Homework",
"recentSubmissions": "Recent Submissions",
"classPerformance": "Class Performance",
"recentGrades": "Recent Grades"
"recentGrades": "Recent Grades",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications"
},
"table": {
"name": "Name",
@@ -148,7 +152,9 @@
"noData": "No data available",
"noDataDesc": "Publish assignments to see class performance trends.",
"noGradedWork": "No graded work yet",
"noGradedWorkDesc": "Finish and submit assignments to see your score trend."
"noGradedWorkDesc": "Finish and submit assignments to see your score trend.",
"noNotifications": "No notifications",
"noNotificationsDesc": "You have no new notifications."
},
"badge": {
"activeSessions": "{count} active sessions",
@@ -177,6 +183,26 @@
"latest": "Latest",
"points": "Points"
},
"timeRange": {
"label": "Time range",
"today": "Today",
"week": "This week",
"month": "This month"
},
"comparison": {
"label": "Comparison",
"vsLastPeriod": "vs last period",
"increase": "Up {value}%",
"decrease": "Down {value}%",
"noChange": "No change",
"noData": "No comparison data"
},
"export": {
"label": "Export",
"csv": "Export CSV",
"success": "Export succeeded",
"failed": "Export failed"
},
"schedule": {
"noDueDate": "No due date",
"scrollForMore": "Scroll for more",

View File

@@ -3,7 +3,13 @@
"student": "Student Diagnostic",
"class": "Class Diagnostic",
"reportList": "Diagnostic Reports",
"myDiagnostic": "My Diagnostic"
"myDiagnostic": "My Diagnostic",
"teacherReportList": "Learning Diagnostic",
"teacherReportListDesc": "View and manage diagnostic reports based on knowledge point mastery.",
"teacherStudent": "Student Diagnostic",
"teacherStudentDesc": "Knowledge point mastery analysis and diagnostic reports.",
"teacherClass": "Class Diagnostic",
"teacherClassDesc": "Class-level knowledge point mastery overview and student attention list."
},
"type": {
"individual": "Individual",
@@ -75,7 +81,7 @@
"empty": "No strength knowledge points"
},
"weaknesses": {
"title": "Weaknesses (<60%)",
"title": "Weaknesses (<80%)",
"practice": "Practice",
"empty": "No weakness knowledge points"
},
@@ -92,13 +98,21 @@
"deleteFailed": "Failed to delete",
"loadFailed": "Failed to load",
"exportFailed": "Export failed",
"retry": "Retry"
"retry": "Retry",
"classLoadFailed": "Class diagnostic loading failed",
"classLoadFailedDesc": "Sorry, an unexpected error occurred while loading class diagnostic data. Please try again later.",
"studentLoadFailed": "Student diagnostic loading failed",
"studentLoadFailedDesc": "Sorry, an unexpected error occurred while loading student diagnostic data. Please try again later.",
"parentLoadFailed": "Children diagnostic loading failed",
"parentLoadFailedDesc": "Sorry, an unexpected error occurred while loading children diagnostic data. Please try again later.",
"reportNotFound": "Report not found"
},
"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",
"heatmapCellAriaLabel": "{name}: {level}%, {label}, {mastered}/{total}",
"masteryLevelExcellent": "Excellent",
"masteryLevelGood": "Good",
"masteryLevelNeedsImprovement": "Needs Improvement",
@@ -150,15 +164,6 @@
"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",
@@ -199,6 +204,52 @@
"weaknesses": "Weaknesses",
"reports": "Diagnostic Reports",
"noReports": "No diagnostic reports for this child yet.",
"noReportsTitle": "No Diagnostic Reports",
"noReportsDescription": "No diagnostic reports for this child yet. Generated reports will appear here.",
"mastery": "Mastery"
},
"reportContent": {
"studentSummary": "Student {studentName} achieved an overall mastery of {score}% during {period}, covering {total} knowledge points ({strengths} strengths, {weaknesses} weaknesses).",
"studentRecommendation": "Review the knowledge point \"{kpName}\" and practice more to improve mastery (current: {level}%).",
"studentNoWeakness": "Overall mastery is good. Maintain the current learning pace and try more challenging problems.",
"classSummary": "Class {className} achieved an overall mastery of {score}% during {period}, with {students} students and {attention} needing attention.",
"classRecommendation": "Class mastery for \"{kpName}\" is low ({level}%). Consider targeted review and practice sessions.",
"classNoWeakness": "Class overall mastery is good. Maintain the current teaching pace.",
"gradeSummary": "Grade {gradeName} achieved an overall mastery of {score}% during {period}, with {students} students and {attention} needing attention.",
"gradeRecommendation": "Grade mastery for \"{kpName}\" is low ({level}%). Consider coordinating grade-wide targeted review sessions.",
"gradeNoWeakness": "Grade overall mastery is good. Maintain the current teaching pace."
},
"exportContent": {
"sheetOverview": "Report Overview",
"sheetMastery": "Knowledge Point Mastery",
"sheetClassStats": "Knowledge Point Statistics",
"sheetAttentionStudents": "Students Needing Attention",
"metricStudent": "Student Name",
"metricClass": "Class",
"metricPeriod": "Report Period",
"metricScore": "Overall Score",
"metricStatus": "Report Status",
"metricGeneratedBy": "Generated By",
"metricCreatedAt": "Created At",
"metricSummary": "Summary",
"metricStrengths": "Strengths",
"metricWeaknesses": "Weaknesses",
"metricRecommendations": "Recommendations",
"metricReportType": "Report Type",
"metricStudentCount": "Student Count",
"metricAttentionCount": "Attention Count",
"colKnowledgePoint": "Knowledge Point",
"colMasteryLevel": "Mastery Level",
"colTotalQuestions": "Total Questions",
"colCorrectQuestions": "Correct",
"colLastAssessed": "Last Assessed",
"colMasteredCount": "Mastered (≥80%)",
"colNotMasteredCount": "Not Mastered (<60%)",
"colTotalStudents": "Total Students",
"colStudentName": "Student Name",
"colAverageMastery": "Average Mastery",
"colWeakCount": "Weak Count",
"noAttentionStudents": "All students are above the attention threshold",
"filename": "diagnostic_report_{period}_{date}.xlsx"
}
}

View File

@@ -3,6 +3,7 @@
"adminList": "Elective Courses",
"create": "Create Course",
"edit": "Edit Course",
"detail": "Course Details",
"teacher": "My Elective Courses",
"student": "Course Selection"
},
@@ -10,6 +11,7 @@
"adminList": "Manage elective courses, open/close selection and lottery.",
"create": "Create a new elective course.",
"edit": "Update elective course details.",
"detail": "View elective course details and student selections.",
"teacher": "View and manage the elective courses you teach.",
"student": "Browse available courses and make selections."
},
@@ -48,7 +50,9 @@
"selectionStart": "Selection Start",
"selectionEnd": "Selection End",
"selectionMode": "Selection Mode",
"credit": "Credit"
"credit": "Credit",
"dropDeadline": "Drop Deadline",
"dropReason": "Drop Reason"
},
"actions": {
"create": "Create Course",
@@ -72,7 +76,49 @@
"createTitle": "Create Elective Course",
"editTitle": "Edit Elective Course",
"namePlaceholder": "Enter course name",
"descriptionPlaceholder": "Enter course description"
"descriptionPlaceholder": "Enter course description",
"nameLabel": "Course Name *",
"subjectLabel": "Subject",
"gradeLabel": "Grade",
"teacherLabel": "Teacher",
"capacityLabel": "Capacity",
"classroomLabel": "Classroom",
"scheduleLabel": "Schedule",
"schedulePlaceholder": "e.g. Mon 14:00-15:30",
"creditLabel": "Credit",
"startDateLabel": "Start Date",
"endDateLabel": "End Date",
"selectionStartLabel": "Selection Start",
"selectionEndLabel": "Selection End",
"dropDeadlineLabel": "Drop Deadline",
"dropDeadlineHint": "Students cannot drop after this time. Leave empty for no restriction.",
"descriptionLabel": "Description",
"cancelButton": "Cancel",
"saveButton": "Save",
"createButton": "Create",
"savingButton": "Saving...",
"selectSubjectPlaceholder": "Select a subject",
"selectGradePlaceholder": "Select a grade",
"selectTeacherPlaceholder": "Select a teacher",
"selectModePlaceholder": "Select selection mode",
"invalidFormState": "Invalid form state",
"saveFailed": "Failed to save course"
},
"stats": {
"totalCourses": "Total Courses",
"totalEnrolled": "Total Enrolled",
"avgUtilization": "Avg Utilization",
"pendingLottery": "Pending Lottery",
"utilization": "{enrolled}/{capacity}",
"utilizationRate": "{rate}%"
},
"parent": {
"title": "Children Elective",
"description": "View your children's elective selections.",
"noChildrenTitle": "No Children Linked",
"noChildrenDescription": "You have no linked children, cannot view elective info.",
"noRecordsTitle": "No Selections",
"noRecordsDescription": "Your child has not selected any courses."
},
"student": {
"mySelections": "My Selections",
@@ -83,7 +129,19 @@
"capacityFull": "Capacity full",
"selectSuccess": "Course selected successfully",
"dropSuccess": "Course dropped successfully",
"confirmDrop": "Are you sure you want to drop this course?"
"confirmDrop": "Are you sure you want to drop this course?",
"dropReasonPlaceholder": "Optional: enter drop reason (max 255 chars)"
},
"detail": {
"studentsTitle": "Selection Roster",
"noStudents": "No Students",
"noStudentsDescription": "No students have selected this course yet.",
"back": "Back to List",
"editCourse": "Edit Course",
"studentName": "Student Name",
"priority": "Priority",
"selectedAt": "Selected At",
"enrolledAt": "Enrolled At"
},
"lottery": {
"result": "Lottery result: {enrolled} enrolled, {waitlist} waitlisted",
@@ -95,11 +153,28 @@
"capacityFull": "Course capacity is full",
"alreadySelected": "You have already selected this course",
"selectionClosed": "Selection is closed",
"selectionNotOpen": "Selection is not open",
"selectionNotStarted": "Selection has not started yet",
"selectionEnded": "Selection has ended",
"courseNotFound": "Course not found",
"noActiveSelection": "No active selection found",
"gradeMismatch": "Your grade does not match the course requirement",
"scheduleConflict": "Schedule conflicts with your existing courses",
"creditExceeded": "Credit limit exceeded ({current}/{max})",
"invalidForm": "Invalid form data",
"unexpected": "Unexpected error"
"unexpected": "Unexpected error",
"dropDeadlinePassed": "Drop deadline has passed, cannot drop course",
"title": "Loading failed",
"description": "Page data failed to load, please retry later."
},
"export": {
"courseSheetName": "Course List",
"selectionSheetName": "Selection Roster",
"statusHeader": "Status",
"priorityHeader": "Priority",
"selectedAtHeader": "Selected At",
"enrolledAtHeader": "Enrolled At",
"indexHeader": "#"
},
"messages": {
"created": "Elective course created",
@@ -110,5 +185,16 @@
"lotteryCompleted": "Lottery completed: {enrolled} enrolled, {waitlist} waitlisted",
"courseDropped": "Course dropped",
"ownershipCheckFailed": "Ownership check failed"
},
"settings": {
"creditLimitDefault": "Default Credit Limit",
"creditLimitDefaultDescription": "Global term credit limit used when no grade-specific limit is configured.",
"capacityNotifyThreshold": "Capacity Notify Threshold",
"capacityNotifyThresholdDescription": "Notify teacher/admin when enrolled count reaches this fraction of capacity (0-1, default 0.9).",
"saved": "Settings saved"
},
"notifications": {
"capacityWarningTitle": "Capacity Warning: {courseName}",
"capacityWarningContent": "Course \"{courseName}\" has reached {enrolled}/{capacity} enrollments ({percent}% threshold). Consider expanding capacity or closing selection."
}
}

View File

@@ -7,7 +7,12 @@
"learning": "Learning",
"mastered": "Mastered",
"dueReview": "Due Review",
"masteredRate": "Mastery Rate"
"masteredRate": "Mastery Rate",
"totalDesc": "Total collected errors",
"newDesc": "Not yet reviewed",
"learningDesc": "Being reviewed",
"masteredDesc": "Mastery rate {rate}%",
"dueReviewDesc": "Due today"
},
"status": {
"new": "New",
@@ -36,7 +41,9 @@
"saveNote": "Save Note",
"archive": "Archive",
"delete": "Delete",
"collect": "Collect Errors"
"collect": "Collect Errors",
"cancel": "Cancel",
"adding": "Adding..."
},
"fields": {
"question": "Select Question",
@@ -45,7 +52,24 @@
"masteryLevel": "Mastery Level",
"reviewCount": "Review Count",
"nextReview": "Next Review",
"createdAt": "Added At"
"createdAt": "Added At",
"student": "Student",
"className": "Class"
},
"masteryLevel": {
"0": "Not Started",
"1": "Beginner",
"2": "Familiar",
"3": "Proficient",
"4": "Skilled",
"5": "Mastered"
},
"questionType": {
"single_choice": "Single Choice",
"multiple_choice": "Multiple Choice",
"judgment": "True/False",
"text": "Short Answer",
"composite": "Composite"
},
"errorTags": {
"concept": "Concept Gap",
@@ -56,13 +80,59 @@
"memory": "Memory Error",
"time": "Out of Time"
},
"itemCard": {
"questionDeleted": "Question deleted",
"questionContent": "Question content",
"difficulty": "Difficulty {level}",
"mastery": "Mastery: {level}",
"reviewTimes": "Reviewed {count} times",
"needReview": "Needs review",
"nextReview": "Next {date}",
"addedAt": "Added on {date}",
"masteryOutOf": "Mastery: {level}/5"
},
"detailDialog": {
"question": "Question",
"myAnswer": "My Answer",
"correctAnswer": "Correct Answer",
"aiAnalysis": "AI Analysis",
"reviewSelf": "Self Review",
"studyNote": "Study Note",
"notePlaceholder": "Record your reflections, solutions, common mistakes...",
"errorTagsLabel": "Error Reason Tags",
"reviewHistory": "Review History",
"questionDeleted": "Question deleted",
"variantPractice": "Variant Practice",
"variantPracticeDesc": "Practice with variants from this error"
},
"filters": {
"searchPlaceholder": "Search notes...",
"status": "Status",
"source": "Source",
"review": "Review",
"allStatus": "All Status",
"allSource": "All Sources",
"allErrors": "All Errors",
"dueOnly": "Due Only"
},
"addDialog": {
"title": "Add Error",
"description": "Select a question from the question bank to add to your error book. Errors are also auto-collected after completing homework/exams.",
"selectQuestion": "Select Question",
"selectPlaceholder": "Select from bank...",
"noteLabel": "Study Note (optional)",
"notePlaceholder": "Record error reasons, solutions...",
"errorTagsLabel": "Error Reason Tags",
"questionPreview": "Question"
},
"empty": {
"title": "Error book is empty",
"description": "Wrong answers from exams and homework will be collected here automatically. You can also add manually."
},
"teacher": {
"title": "Error Analysis",
"description": "View class error statistics and weak knowledge points",
"description": "View class error statistics and weak knowledge points to support precision teaching",
"descriptionShort": "View class error statistics and weak knowledge points by subject.",
"coverage": "Student Coverage",
"totalErrors": "Total Errors",
"avgMastery": "Avg Mastery Rate",
@@ -71,7 +141,16 @@
"studentDetail": "Student Error Details",
"topWrong": "Top Wrong Questions",
"noClass": "No classes assigned",
"noStudent": "No students in class"
"noClassDesc": "You have not been assigned to any class. Unable to view error analysis data.",
"noStudent": "No students in class",
"noStudentDesc": "There are no students in the class. Unable to view error analysis data.",
"noChapterDataTitle": "No Chapter Error Data",
"noChapterDataDesc": "Knowledge points have not been linked to chapters. Unable to display chapter-level statistics.",
"noKpDataTitle": "No Knowledge Point Data",
"noKpDataDesc": "Errors have not been linked to knowledge points. Unable to display weak point statistics.",
"noStudentErrorsTitle": "No Student Errors",
"noStudentErrorsDesc": "No student error data for the selected range.",
"studentsCount": "{total} students total, {withErrors} with errors"
},
"parent": {
"title": "Child Error Book",
@@ -89,7 +168,7 @@
},
"admin": {
"title": "School-wide Error Analysis",
"description": "School-wide error statistics and weak point analysis",
"description": "School-wide error statistics and weak point analysis for teaching decisions",
"description2": "View school-wide error statistics and weak knowledge points by subject.",
"noPermissionTitle": "Insufficient Permissions",
"noPermissionDescription": "You do not have permission to view school-wide error analysis data.",
@@ -104,6 +183,100 @@
"noStudentErrorsTitle": "No Student Errors",
"noStudentErrorsDescription": "No student error data for the selected subject."
},
"analyticsStats": {
"coverage": "Coverage",
"coverageSub": "/ {total}",
"totalErrors": "Total Errors",
"totalErrorsSub": "{avg} per student",
"avgMastery": "Avg Mastery",
"avgMasteryGood": "Good",
"avgMasteryNeedImprove": "Needs improvement",
"dueReview": "Due Review",
"dueReviewNeedAttention": "Needs attention",
"dueReviewNone": "None due",
"knowledgePoints": "Knowledge Points",
"knowledgePointsWide": "Wide range",
"knowledgePointsFocused": "Focused"
},
"subjectTabs": {
"all": "All Subjects",
"dueReview": "Due {count}"
},
"classFilter": {
"all": "All Classes",
"errorCount": "{count} errors",
"dueReview": "{count} due"
},
"topWrong": {
"title": "Top Wrong Questions",
"topTitle": "Top 10 Wrong Questions",
"emptyTitle": "No frequent wrong questions",
"emptyDesc": "After students complete homework or exams, frequency statistics will appear here.",
"errorCount": "{count} students wrong",
"masteredCount": "{count} mastered",
"masteryRate": "Mastery rate {rate}%"
},
"weaknessChart": {
"title": "Weak Knowledge Points Top {count}",
"errorCount": "Errors",
"chapterLabel": "Chapter: {title}",
"masteredLabel": "Mastered",
"masteryRateLabel": "Mastery Rate",
"unclassified": "Unclassified"
},
"chapterChart": {
"title": "Chapter Error Distribution",
"errorCount": "Errors",
"masteredLabel": "Mastered",
"masteryRateLabel": "Mastery Rate",
"knowledgePointCount": "Knowledge Points",
"weakKpsLabel": "Weak points:",
"knowledgePointBadge": "{count} knowledge points"
},
"classErrorBar": {
"title": "Class Error Comparison",
"errorCount": "Total Errors",
"studentCount": "Students",
"avgPerStudent": "Avg per student",
"avgMastery": "Avg Mastery",
"dueReview": "Due Review"
},
"subjectDistChart": {
"title": "Subject Error Distribution",
"errorCount": "Errors",
"masteredLabel": "Mastered",
"masteryRateLabel": "Mastery Rate"
},
"groupedTable": {
"unclassified": "Unclassified",
"studentCount": "{count}",
"studentsWithErrors": "{count} with errors",
"totalErrors": "Total Errors",
"avgMastery": "Avg Mastery",
"student": "Student",
"new": "New",
"learning": "Learning",
"mastered": "Mastered",
"dueReview": "Due",
"masteryRate": "Mastery Rate",
"unknown": "Unknown"
},
"classOverview": {
"coverage": "Coverage",
"coverageDesc": "Students with error records",
"totalErrors": "Total Errors",
"totalErrorsDesc": "Class cumulative errors",
"avgMastery": "Avg Mastery",
"avgMasteryDesc": "Mastered error ratio",
"weakPoints": "Weak Points",
"weakPointsDesc": "Needs focus",
"weakPointsTitle": "Weak Knowledge Points Top 10",
"subjectDist": "Subject Distribution",
"noData": "No data",
"errorsAndMastery": "{count} errors \u00b7 {rate}% mastery",
"noStudentData": "No student error data",
"noStudentDataDesc": "After students complete homework or exams, error data will be summarized here."
},
"messages": {
"added": "Error added",
"noteSaved": "Note saved",
@@ -118,6 +291,8 @@
"archiveFailed": "Archive failed",
"collectFailed": "Failed to collect errors",
"notFound": "Error not found or access denied",
"selectQuestion": "Please select a question"
"selectQuestion": "Please select a question",
"recordFailed": "Record failed",
"addedShort": "Added"
}
}

View File

@@ -120,7 +120,10 @@
},
"error": {
"notFound": "Exam not found",
"loadFailed": "Failed to load exam"
"loadFailed": "Failed to load exam",
"boundaryTitle": "Failed to load exam data",
"boundaryDescription": "An error occurred while loading this section. Please retry.",
"retry": "Retry"
},
"analytics": {
"title": "Exam Analytics",
@@ -149,6 +152,59 @@
"noData": "No analytics data yet. Data will be available after students submit and grading is complete.",
"viewAnalytics": "View Analytics"
},
"build": {
"title": "Build Exam",
"description": "Assemble questions for your exam.",
"examStructure": "Exam Structure",
"questionBank": "Question Bank",
"preview": "Preview",
"totalScore": "Total Score",
"saveDraft": "Save Draft",
"publish": "Publish",
"richEditor": "Rich Editor",
"richEditorHint": "Switch to rich text editor",
"loaded": "loaded",
"startHint": "Start by adding questions from the right panel",
"itemsInStructure": "{{count}} items in structure",
"saveSuccess": "Exam draft saved",
"saveFailed": "Save failed",
"publishSuccess": "Published exam",
"publishFailed": "Publish failed",
"loadQuestionsFailed": "Failed to load questions",
"newGroup": "New Group",
"newSection": "New Section",
"score": "Score",
"sectionTitlePlaceholder": "Section Title (e.g. Part I)",
"groupTitlePlaceholder": "Group Title",
"pts": "pts",
"dragItemsHere": "Drag items here",
"addGroup": "+ Add Group",
"addSection": "+ Add Section",
"section": "Section",
"group": "Group",
"question": "Question",
"questionContent": "Question content",
"noQuestionsFound": "No questions found matching your filters.",
"noContentPreview": "No content preview",
"loading": "Loading...",
"loadMore": "Load More",
"aiVariant": "AI Variant",
"noQuestionsSelected": "No questions selected. Add questions from the bank or create a group.",
"createGroup": "Create Group",
"createSection": "Create Section",
"dragGroupsHere": "Drag groups or questions here",
"dragQuestionsHere": "Drag questions here or add from bank",
"untitledGroup": "Untitled Group",
"previewSubject": "Subject",
"previewGrade": "Grade",
"previewTime": "Time",
"previewTotal": "Total",
"previewClass": "Class",
"previewName": "Name",
"previewNo": "No.",
"emptyExamPaper": "Empty Exam Paper",
"minutes": "min"
},
"richEditor": {
"title": "Rich Text Exam Builder",
"description": "Paste exam text, use toolbar to mark questions/groups/blanks/dotted chars, live preview on the right",
@@ -201,6 +257,167 @@
"essay": "Essay",
"text": "Text"
}
},
"preview": {
"selectQuestionHint": "Select a question on the left to edit",
"editorTitle": "Question Editor",
"editorDescription": "Edit directly or rewrite via AI instruction",
"stem": "Question Stem",
"aiRewriteInstruction": "AI Rewrite Instruction",
"aiRewritePlaceholder": "e.g. Make this question harder, add a distractor, keep the total score unchanged.",
"aiRewriteButton": "AI Rewrite Current Question",
"rawModelOutput": "Raw Model Output"
},
"subQuestions": {
"title": "Sub-questions",
"add": "Add Sub-question",
"contentPlaceholder": "Sub-question content",
"answerPlaceholder": "Reference answer",
"scorePlaceholder": "Score",
"empty": "No sub-questions for this question"
},
"richForm": {
"sectionLabel": "Section",
"partLabel": "Part",
"groupLabel": "Group",
"questionCountSummary": "({{count}} questions, {{score}} pts)",
"typeSingleChoice": "Single Choice",
"typeMultipleChoice": "Multiple Choice",
"typeJudgment": "Judgment",
"typeComposite": "Composite",
"typeShortAnswer": "Short Answer",
"scoreUnit": "pts",
"emptyPreview": "No questions yet, add them in the editor on the left",
"missingExamId": "Missing exam ID",
"backToBuild": "Back to Build"
},
"editor": {
"loading": "Loading editor...",
"contentPlaceholder": "Paste or type exam content here, select text to mark as questions/groups/dotted/blanks..."
},
"selectionToolbar": {
"ariaLabel": "Selection marking toolbar",
"sectionLabel": "Section",
"groupLabel": "Group",
"singleChoice": "Single",
"blankShortAnswer": "Blank/Short",
"composite": "Composite",
"image": "Image",
"defaultGroupTitle": "I. Multiple Choice",
"defaultSectionTitle": "Volume I - Multiple Choice"
},
"previewHook": {
"taskInterrupted": "Task was interrupted after page refresh, please regenerate",
"untitledExam": "Untitled Exam",
"queuedSuccess": "Added to background queue, you can continue editing",
"backgroundComplete": "Background generation complete: {{title}}",
"backgroundFailed": "Background generation failed: {{title}}",
"selectQuestionFirst": "Please select a question first",
"questionNotFound": "Selected question not found",
"enterRewriteInstruction": "Please enter rewrite instruction",
"aiRewriteFailed": "AI rewrite failed",
"aiRewriteSuccess": "Question rewritten per instruction",
"generatePreviewFailed": "Failed to generate preview",
"pasteSourceFirst": "Please paste the full exam text first"
},
"paperPreview": {
"scoreWithUnit": "({{score}} pts)"
},
"actionMessages": {
"enterRewriteInstruction": "Please enter rewrite instruction",
"noSelectedQuestion": "No selected question data",
"questionFormatInvalid": "Selected question format invalid",
"questionsDataInvalid": "Invalid question data format",
"structureDataInvalid": "Invalid exam structure data format",
"dbCreateFailed": "Database error: Failed to create exam",
"createdSuccess": "Exam created successfully.",
"analyzeFirst": "Please analyze and preview before creating",
"pasteSourceFirst": "Please paste the full exam text first",
"aiQuestionFormatInvalid": "AI question format invalid",
"invalidUpdateData": "Invalid update data",
"onlyOwnUpdate": "You can only update exams you created",
"dbUpdateFailed": "Database error: Failed to update exam",
"updated": "Exam updated",
"invalidDeleteData": "Invalid delete data",
"onlyOwnDelete": "You can only delete exams you created",
"dbDeleteFailed": "Database error: Failed to delete exam",
"deleted": "Exam deleted",
"invalidDuplicateData": "Invalid duplicate data",
"notFound": "Exam not found",
"dbDuplicateFailed": "Database error: Failed to duplicate exam",
"duplicated": "Exam duplicated",
"loadPreviewFailed": "Failed to load exam preview",
"loadSubjectsFailed": "Failed to load subjects",
"loadGradesFailed": "Failed to load grades",
"invalidGradeId": "Invalid grade id",
"titleRequired": "Please enter exam title",
"contentRequired": "Exam content cannot be empty",
"contentInvalid": "Invalid exam content format",
"contentParseFailed": "Failed to parse exam content",
"draftCreated": "Exam draft created",
"examUpdated": "Exam updated",
"sourceTextRequired": "Exam source text cannot be empty",
"autoMarkCompleted": "AI auto-marking completed",
"noQuestionText": "(no question text)"
},
"card": {
"level": "Lvl {{level}}",
"minutes": "{{count}} min",
"points": "{{count}} pts",
"questions": "{{count}} Questions"
},
"viewer": {
"section": "Section",
"group": "Group",
"score": "Score",
"scoreLabel": "Score: {{score}}",
"noQuestions": "No questions available.",
"unknown": "unknown"
},
"previewDialog": {
"section": "Section",
"title": "Exam Preview",
"generating": "Generating preview...",
"fullPreview": "Full exam preview",
"summary": "{{count}} questions · {{subject}} · {{grade}} · {{minutes}} min · {{total}} pts",
"noPreview": "No preview available",
"confirmCreate": "Confirm & Create",
"untitledQuestion": "Untitled question",
"untitledSubQuestion": "Untitled sub-question",
"scoreUnit": "pts"
},
"questionOptions": {
"label": "Options",
"addOption": "Add option",
"correct": "Correct",
"markCorrectAria": "Mark option {{id}} as correct answer",
"deleteOptionAria": "Delete option"
},
"editorExtensions": {
"blank": {
"ariaLabel": "Blank"
},
"group": {
"titlePlaceholder": "Group title (e.g. I. Multiple Choice)",
"instructionPlaceholder": "Instruction (e.g. 3 pts each, 24 pts total) — optional, total auto-calculated",
"statsSummary": "{{count}} questions · {{score}} pts"
},
"question": {
"typeSingleChoice": "Single Choice",
"typeMultipleChoice": "Multiple Choice",
"typeJudgment": "True/False",
"typeText": "Fill/Short Answer",
"typeComposite": "Composite",
"scoreUnit": "pts"
},
"section": {
"titlePlaceholder": "Section title (e.g. Part I Multiple Choice)",
"levelTitle": "Level",
"levelVolume": "Volume",
"levelPart": "Part",
"levelSubVolume": "Sub-volume",
"statsSummary": "{{count}} questions · {{score}} pts"
}
}
},
"homework": {
@@ -255,7 +472,19 @@
"titleRequired": "Please enter a title",
"selectClassRequired": "Please select a class",
"createSuccess": "Assignment created",
"createFailed": "Failed to create"
"createFailed": "Failed to create",
"createDescription": "Quickly publish a text assignment or derive from an exam.",
"noClassesAvailable": "No classes available",
"noClassesDescription": "Create a class first, then publish homework to that class.",
"goToClasses": "Go to Classes",
"error": {
"invalidDate": "Invalid date format",
"titleRequired": "Title is required for quick assignments",
"dueAfterAvailable": "Due date must be after the available date",
"lateDueAfterDue": "Late due date must be after the normal due date",
"lateDueRequired": "Late due date is required when late submission is allowed",
"invalidForm": "Invalid form data"
}
},
"take": {
"questions": "Questions",
@@ -454,10 +683,101 @@
"notAvailableYet": "Not available yet",
"noAttemptsLeft": "No attempts left",
"assignmentNotFound": "Assignment not found",
"assignmentNotAvailable": "Assignment not available"
"assignmentNotAvailable": "Assignment not available",
"loadFailed": "Failed to load, please try again later",
"scanNotFound": "Scan image not found or access denied"
},
"filters": {
"searchPlaceholder": "Search assignments...",
"status": "Status",
"allStatus": "All Status",
"statusPending": "Pending",
"statusSubmitted": "Submitted",
"statusGraded": "Graded"
},
"analytics": {
"examContent": "Exam Content",
"questionPreview": "Question Preview",
"errorAnalysis": "Error Analysis",
"errorRateOverview": "Error Rate Overview",
"errorRateAriaLabel": "Error rate {{rate}}%",
"question": "Question",
"errors": "Errors",
"errorRateLabel": "Error Rate",
"wrongAnswersWithCount": "Wrong Answers ({{count}})",
"wrongAnswers": "Wrong Answers",
"noWrongAnswers": "No wrong answers recorded.",
"studentAnswer": "Student Answer",
"studentCount": "{{count}} student(s)",
"notAnswered": "Not answered",
"selectQuestionHint": "Select a question from the left",
"selectQuestionHintDesc": "to view error analysis",
"noGradedSubmissions": "No graded submissions yet."
},
"scanViewer": {
"noImages": "No scan images uploaded by this student",
"noImagesHint": "Students can photograph and upload paper answers on the answer page",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"rotate": "Rotate",
"fullscreen": "Fullscreen",
"pageIndicator": "Page {{current}} / {{total}}",
"answerImageAlt": "Answer image page {{page}}",
"thumbnailAlt": "Thumbnail {{page}}"
},
"submissions": {
"title": "Submissions",
"description": "View submission and grading progress by assignment.",
"emptyDescription": "No assignments to grade yet.",
"quickAssignment": "Quick Assignment",
"columns": {
"assignment": "Assignment"
}
},
"detail": {
"viewSubmissions": "View Submissions",
"dueLabel": "Due",
"noDueDate": "No due date",
"submissionsLabel": "Submissions",
"performanceAnalytics": "Performance Analytics",
"assignmentContent": "Assignment Content"
},
"excellent": {
"title": "Excellent Submissions",
"description": "Top submissions scoring {{minPercentage}}% or above in this assignment.",
"empty": "No excellent submissions yet.",
"emptyHint": "They will appear here after grading is complete.",
"loading": "Loading excellent submissions...",
"loadFailed": "Failed to load",
"retry": "Retry",
"rank": "Rank {{rank}}",
"scoreLabel": "Score",
"scoreValue": "{{score}} / {{max}}",
"percentage": "{{value}}%",
"lateTag": "Late",
"viewDetail": "View Details",
"submittedAt": "Submitted on {{date}}",
"studentAnon": "Student"
},
"parentExam": {
"examsTaken": "Exams Taken",
"averageScore": "Average Score",
"bestScore": "Best Score",
"examResults": "{{name}}'s Exam Results",
"examResultsDescription": "Recent exam scores and performance trends",
"noResults": "No exam results",
"noResultsHint": "Exam results will appear here once available.",
"pass": "Pass",
"belowPass": "Below 60%",
"viewGrades": "View Grades"
}
},
"proctoring": {
"page": {
"noPermission": "You do not have proctoring permission.",
"title": "Exam Proctoring",
"description": "Monitor student activity during the exam."
},
"mode": {
"title": "Exam Mode",
"description": "Select exam mode and configure options. Proctored mode enables anti-cheat monitoring.",

View File

@@ -1,4 +1,88 @@
{
"title": "File Management",
"description": "View and manage all uploaded files in the system."
}
"description": "View and manage all uploaded files in the system.",
"upload": {
"title": "Click to upload or drag and drop",
"hint": "Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {size}",
"success": "{name} uploaded",
"error": "{name}: {message}",
"empty": "File is empty",
"tooLarge": "File size exceeds {limit} limit",
"invalidType": "File type {type} is not allowed",
"uploaded": "Uploaded",
"remove": "Remove",
"networkError": "Network error",
"invalidResponse": "Invalid response"
},
"list": {
"empty": "No files",
"emptyDescription": "There are no files yet.",
"download": "Download",
"delete": "Delete",
"deleted": "File deleted",
"deleteFailed": "Failed to delete file"
},
"preview": {
"trigger": "Preview",
"title": "File preview",
"download": "Download",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"office": {
"title": "Office file preview not available",
"hint": "Download the file to view its contents in your Office application."
},
"other": {
"title": "Preview not available",
"hint": "Download the file to view its contents."
},
"text": {
"title": "Text file",
"hint": "Click below to load the content.",
"load": "Load preview",
"loading": "Loading...",
"error": "Failed to load text: {message}"
}
},
"admin": {
"title": "Files",
"subtitle": "Upload and manage all files in the system.",
"stats": {
"totalFiles": "Total Files",
"totalSize": "Total Size"
},
"filter": {
"byType": "Filter by type",
"search": "Search by file name...",
"allTypes": "All Types",
"images": "Images",
"pdf": "PDF",
"word": "Word",
"wordDocx": "Word (docx)",
"excel": "Excel",
"excelXlsx": "Excel (xlsx)",
"powerpoint": "PowerPoint",
"powerpointPptx": "PowerPoint (pptx)",
"text": "Text",
"zip": "ZIP"
},
"selection": {
"selected": "{count} selected",
"deleteSelected": "Delete Selected",
"deleting": "Deleting...",
"deleted": "Deleted {count} file(s)",
"deleteFailed": "Failed to delete files"
},
"empty": {
"title": "No files found",
"description": "Try adjusting your filters or upload a new file."
},
"columns": {
"file": "File",
"size": "Size",
"type": "Type",
"uploaded": "Uploaded",
"actions": "Actions"
}
}
}

View File

@@ -256,6 +256,9 @@
"pasteHint": "Tip: Copy multiple rows/columns from Excel and paste to fill in bulk. Press Enter to go to next row, Tab to go to next cell.",
"confirmSwitchExam": "Switching exam will clear entered scores. Continue?",
"confirmSwitchClass": "Switching class will clear entered scores. Continue?",
"confirmTitle": "Confirm Switch",
"confirmAction": "Switch",
"cancelAction": "Cancel",
"saveAll": "Save All Grades",
"saving": "Saving...",
"clear": "Clear",
@@ -331,7 +334,9 @@
"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"
"tooltipOfTotal": "{percentage}% of total",
"yourPosition": "Your position: {score} pts ({bucket} range)",
"yourPositionAriaLabel": "Your position: latest score {score} pts, highlighted range"
},
"subjectComparison": {
"descriptionEmpty": "Compare performance across subjects for the selected class.",
@@ -359,7 +364,17 @@
"significanceAriaLabel": "Significance analysis: {level}",
"significanceDetails": "View detailed analysis",
"significanceTopClass": "Top class: {name} ({score} points)",
"significanceBottomClass": "Bottom class: {name} ({score} points)"
"significanceBottomClass": "Bottom class: {name} ({score} points)",
"significanceStats": "p = {pValue} · Cohen's d = {cohensD} ({effectSize})",
"significanceMethod": "Welch's t-test: {significant}",
"significantYes": "Statistically significant (p < 0.05)",
"significantNo": "Not statistically significant (p >= 0.05)",
"effectSize": {
"negligible": "negligible",
"small": "small",
"medium": "medium",
"large": "large"
}
},
"trendChart": {
"descriptionEmpty": "Score progression over time (normalized to 0-100).",
@@ -412,5 +427,317 @@
"recentGrades": "Recent Grades",
"viewAll": "View All",
"noRecentGrades": "No recent grades"
},
"page": {
"list": {
"description": "Manage student grade records.",
"stats": "Statistics",
"batchEntry": "Batch Entry",
"enterGrades": "Enter Grades",
"emptyTitle": "No grade records",
"emptyDescription": "Start entering grades for your classes.",
"emptyActionLabel": "Enter Grades",
"recordUnit": "records"
},
"analytics": {
"description": "Trend analysis, class comparison and score distribution.",
"backToGrades": "Back to grades",
"noClassesTitle": "No classes",
"noClassesDescription": "You don't have any classes yet.",
"trendTitle": "Grade Trend",
"trendEmptyTitle": "No trend data",
"trendEmptyDescription": "No grade trend to display with current filters.",
"distributionTitle": "Score Distribution",
"distributionEmptyTitle": "No distribution data",
"distributionEmptyDescription": "No score distribution to display with current filters.",
"subjectComparisonTitle": "Subject Comparison",
"subjectComparisonEmptyTitle": "No subject comparison data",
"subjectComparisonEmptyDescription": "No subject comparison to display with current filters.",
"classComparisonTitle": "Class Comparison",
"classComparisonEmptyTitle": "No class comparison data",
"classComparisonEmptyDescription": "No class comparison to display with current filters."
},
"entry": {
"title": "Batch Grade Entry",
"description": "Select an exam from the question bank and enter scores per question, like filling in a spreadsheet.",
"noExamsTitle": "No exams available",
"noExamsDescription": "Please create exams and add questions in exam management first before entering grades."
},
"stats": {
"description": "View class grade statistics and rankings.",
"noClassesTitle": "No classes",
"noClassesDescription": "You don't have any classes yet.",
"noAccessTitle": "No accessible classes",
"noAccessDescription": "You don't have permission to view any classes.",
"exportLabel": "Export Grades"
},
"error": {
"title": "Grades page failed to load",
"description": "Sorry, an unexpected error occurred while loading the page. Please try again later.",
"retry": "Retry"
}
},
"schoolWide": {
"gradeCount": "Grades",
"classStudentInfo": "{classCount} classes · {studentCount} students",
"averageScore": "Overall Average",
"recordCount": "Based on {count} grade records",
"passRate": "Pass Rate",
"passLine": "Pass line 60%",
"excellentRate": "Excellence Rate",
"excellentLine": "Excellence line 85%",
"comparisonTitle": "Grade Comparison",
"comparisonDescription": "Average score, pass rate and excellence rate aggregated by grade.",
"empty": "No grade data available",
"columns": {
"schoolGrade": "School / Grade",
"classCount": "Classes",
"studentCount": "Students",
"recordCount": "Records",
"average": "Average",
"passRate": "Pass Rate",
"excellentRate": "Excellence"
}
},
"action": {
"unknownSubject": "Unknown subject",
"scoreEntered": "entered",
"notificationTitle": "Grade entered: {title}",
"studentNotification": "Your {subject} grade {score}. Please view details.",
"parentNotification": "Your child's {subject} grade {score}. Please view details.",
"missingExamId": "Missing exam ID",
"missingClassId": "Missing class ID",
"missingRecords": "Missing grade data",
"missingRecordsData": "Missing grade data",
"examNotFound": "Exam not found or no access",
"examNoQuestions": "Exam has no questions, cannot enter grades",
"examNoSubject": "Exam is not linked to a subject",
"invalidFormData": "Invalid form data",
"invalidId": "Invalid ID",
"invalidQuery": "Invalid query parameters",
"recordCreated": "Grade record created",
"recordUpdated": "Grade record updated",
"recordDeleted": "Grade record deleted",
"recordsCreated": "Entered {count} grade records",
"recordsDeletedCount": "Deleted {count} grade records",
"recordsUndoneCount": "Undone {count} grade records",
"noRecordsToUndo": "No records to undo",
"noRecordsToDelete": "No records to delete",
"cannotUndoMoreThan": "Cannot undo more than 500 records at once",
"cannotDeleteMoreThan": "Cannot delete more than 500 records at once",
"ownGradesOnly": "Can only view your own grades",
"childrenGradesOnly": "Can only view your children's grades",
"ownRecordsOnly": "You can only view your own grade records",
"childrenRecordsOnly": "You can only view your children's grade records",
"ownRankingTrendOnly": "You can only view your own ranking trend",
"childrenRankingTrendOnly": "You can only view your children's ranking trend",
"taughtClassesOnly": "You can only view records of classes you teach",
"studentOnly": "This action is only available to students",
"invalidJson": "Invalid grade data format",
"exportOwnChildrenOnly": "Can only export your own children's grades",
"classIdRequired": "Class ID is required for class export",
"filenameDetail": "grade_records_{date}.xlsx",
"filenameClassReport": "class_grade_report_{date}.xlsx",
"errorExamNoQuestions": "Exam has no questions, cannot enter grades",
"errorQuestionNotInExam": "Question {questionId} does not belong to exam {examId}",
"scopeError": {
"class_taught": "You can only access classes you teach",
"class_members": "You can only access your own class",
"denied": "Access denied for your scope"
}
},
"anomaly": {
"severityWarning": "Warning",
"severityCritical": "Critical",
"notificationTitle": "{subject} score anomaly alert",
"notificationContent": "{subject} score shows a {severity} drop: latest {latestScore} pts, historical avg {historicalAverage} pts, dropped {drop} pts. Please follow up on the student's learning status."
},
"growthArchive": {
"title": "Growth Archive",
"description": "Spanning {years} academic years, {records} records, {subjects} subjects",
"descriptionEmpty": "Longitudinal growth trend across academic years and semesters",
"emptyTitle": "No growth archive data",
"emptyDescription": "Grade records must be linked to an academic year before a growth archive can be generated.",
"ariaLabelEmpty": "Growth archive chart: no data",
"ariaLabelNonEmpty": "Growth archive chart: {count} academic year/semester data points",
"averageScore": "Normalized average score",
"overallAverage": "Overall avg {score}",
"deltaUp": "Overall improvement of {delta} points",
"deltaDown": "Overall decline of {delta} points",
"deltaStable": "Scores remain stable",
"statsAverage": "Avg: {score}",
"statsPassRate": "Pass rate: {rate}%",
"statsRecords": "{records} records · {subjects} subjects"
},
"knowledgePointMastery": {
"title": "Knowledge Point Mastery",
"description": "{count} knowledge points · avg mastery {avg}%",
"descriptionEmpty": "Knowledge point mastery analysis powered by the diagnostic module",
"emptyTitle": "No knowledge point mastery data",
"emptyDescription": "Generate diagnostic reports in the diagnostic module before mastery data becomes available.",
"ariaLabel": "Knowledge point mastery chart: {count} knowledge points",
"averageMastery": "Average mastery",
"viewDetail": "View diagnostic details",
"tooltipMastery": "Mastery: {score}%",
"tooltipStudents": "Mastered {mastered} / {total} students",
"weakPointsTitle": "Weak knowledge points (top 3)",
"weakPointsAriaLabel": "Weak knowledge points list"
},
"reportCard": {
"title": "Term Report Card",
"schoolName": "Smart Campus",
"periodLabel": "Academic Year: {year} · Semester: {semester}",
"allSemesters": "All Semesters",
"studentName": "Student Name",
"className": "Class",
"classTeacher": "Class Teacher",
"generatedAt": "Generated At",
"gradesSectionTitle": "Subject Grades Detail",
"summarySectionTitle": "Summary Statistics",
"commentsTitle": "Teacher Comments",
"commentsPlaceholder": "(Teacher comments to be filled in here)",
"commentsAriaLabel": "Teacher comments area",
"columnSubject": "Subject",
"columnAssessment": "Assessment",
"columnType": "Type",
"columnScore": "Score",
"columnRank": "Rank",
"columnRemark": "Remark",
"subjectAvg": "Avg {score}",
"overallAverage": "Overall Average",
"overallRank": "Class Rank",
"passRate": "Pass Rate",
"excellentRate": "Excellent Rate",
"noRecords": "No records",
"emptyGrades": "No grade records for the selected period.",
"signatureClassTeacher": "Class Teacher Signature",
"signatureParent": "Parent Signature",
"signaturePrincipal": "Principal Signature",
"footerNote": "This report card is auto-generated by the system · Generated on {date}",
"ariaLabel": "Term report card for {name}",
"backToGrades": "Back to Grades",
"print": "Print / Save as PDF",
"preparing": "Preparing print…",
"errorPrint": "Print failed, please try again",
"errorNavigate": "Failed to navigate to report card page",
"openReportCard": "Open Report Card",
"filterAcademicYear": "Academic Year",
"filterSemester": "Semester",
"filterAllAcademicYears": "All Academic Years",
"filterAllSemesters": "All Semesters",
"missingStudentTitle": "Missing Student Information",
"missingStudentDescription": "Please access the report card page via the grades list.",
"emptyTitle": "No Report Card Data",
"emptyDescription": "No grade records match the current filter criteria. Please adjust filters and retry.",
"loading": "Generating report card…",
"errorTitle": "Loading Failed",
"errorDescription": "Failed to load report card. Please try again later.",
"retry": "Retry",
"academicYearsCount": "{count} academic years available"
},
"excelImport": {
"triggerButton": "Excel Import",
"dialogTitle": "Bulk Import Grades",
"dialogDescription": "Bulk import class grades via Excel file. Please download the template first, fill it in, then upload.",
"step1Title": "Step 1: Download Import Template",
"step1Description": "The template includes class student names as examples for accurate filling.",
"downloadTemplate": "Download Template",
"templateFilename": "grades_import_template_{date}.xlsx",
"templateDownloaded": "Template downloaded",
"step2Title": "Step 2: Fill in Entry Parameters",
"step3Title": "Step 3: Upload Excel File",
"assessmentTitle": "Assessment Title",
"assessmentTitlePlaceholder": "e.g., 2026 Spring Midterm Exam",
"fullScore": "Full Score",
"examId": "Linked Exam ID (optional)",
"examIdPlaceholder": "e.g., exam_xxx",
"fileLabel": "Excel File",
"fileHint": "Supports .xlsx / .xls, max 500 rows per upload",
"startImport": "Start Import",
"importing": "Importing…",
"cancel": "Cancel",
"resultTitle": "Import Result",
"resultSuccess": "Successfully imported",
"resultFailed": "Failed",
"unmatchedStudents": "Unmatched students:",
"columnRow": "Row",
"columnStudentName": "Student Name",
"columnScore": "Score",
"columnErrors": "Errors",
"moreErrors": "{count} more errors not shown",
"successMessage": "Successfully imported {success} records, {failed} failed",
"successAllImported": "All {count} records imported successfully",
"successPartial": "Imported {success}, failed {failed}. See details.",
"errorNoFile": "Please select an Excel file",
"errorInvalidFormat": "Only .xlsx and .xls files are supported",
"errorEmptyFile": "No data in file",
"errorTooManyRows": "File contains {count} rows, exceeding the 500 row limit",
"errorInvalidParams": "Parameter validation failed",
"errorMissingFields": "Please fill in class, subject and assessment title",
"errorSelectClass": "Please select a class first",
"errorTemplateDownload": "Template download failed, please retry",
"errorImport": "Import failed, please retry",
"errorAllInvalid": "All {count} rows are invalid"
},
"collabLock": {
"lockConflictTitle": "This class + subject + type is being edited by another teacher",
"lockConflictDescription": "{teacherName} is entering grades. Please try again later or ask them to release the lock.",
"lockConflictByYou": "You already hold this lock and may continue editing.",
"lockConflictUnknownTeacher": "Unknown teacher",
"acquired": "Editing lock acquired",
"acquireFailed": "Failed to acquire editing lock",
"renewed": "Editing lock renewed",
"renewFailed": "Lock renewal failed, it may have been taken by another teacher",
"released": "Editing lock released",
"releaseFailed": "Failed to release editing lock",
"expiredTitle": "Editing lock expired",
"expiredDescription": "You have been inactive for too long. The editing lock has been automatically released. Please re-enter the page to avoid overwriting another teacher's edits.",
"retryAcquire": "Retry acquiring lock",
"lockInfoTooltip": "This editing session is protected by a lock. It auto-releases after 5 minutes of inactivity, preventing concurrent edits by other teachers.",
"heartbeatError": "Heartbeat renewal failed. Please check your network and re-enter the page.",
"noLockHolder": "No lock holder"
},
"appeal": {
"title": "Grade Appeal",
"description": "Submit an appeal if you disagree with a grade. The teacher will review and respond.",
"submitAppeal": "Submit Appeal",
"reasonLabel": "Appeal Reason",
"reasonPlaceholder": "Please explain your appeal in detail (at least 10 characters)…",
"expectedScoreLabel": "Expected Score (optional)",
"expectedScorePlaceholder": "e.g., 85",
"originalScoreLabel": "Original Score",
"status": {
"pending": "Pending Review",
"approved": "Approved",
"rejected": "Rejected",
"withdrawn": "Withdrawn"
},
"reviewCommentLabel": "Review Comment",
"adjustedScoreLabel": "Adjusted Score",
"reviewerLabel": "Reviewer",
"reviewedAtLabel": "Reviewed At",
"createdAtLabel": "Appealed At",
"withdraw": "Withdraw Appeal",
"withdrawConfirm": "Are you sure you want to withdraw this appeal? This cannot be undone.",
"reviewAppeal": "Review Appeal",
"approve": "Approve",
"reject": "Reject",
"reviewCommentPlaceholder": "Please enter your review comment…",
"adjustScoreHint": "Fill in to adjust the score, otherwise leave empty to keep original",
"empty": "No appeals yet",
"pendingEmpty": "No pending appeals for review",
"gradeNotFound": "Grade record not found",
"notYourGrade": "You can only appeal your own grades",
"pendingExists": "This grade already has a pending appeal, please wait for review",
"notFound": "Appeal not found",
"outOfScope": "You are not authorized to review appeals for this class",
"created": "Appeal submitted, please wait for teacher review",
"approved": "Appeal approved, grade updated",
"rejected": "Appeal rejected",
"withdrawn": "Appeal withdrawn",
"viewAppeals": "View Appeals",
"appealFor": "{title} Appeal",
"originalScoreSnapshot": "Score at Appeal Time",
"currentScore": "Current Score"
}
}

View File

@@ -0,0 +1,80 @@
{
"title": "Invitation Codes",
"description": "Generate invitation codes to replace open registration, with pre-assigned roles and class bindings.",
"roles": {
"student": "Student",
"teacher": "Teacher",
"parent": "Parent",
"admin": "Admin"
},
"stats": {
"total": "Total Codes",
"unused": "Unused",
"used": "Used",
"expired": "Expired"
},
"generate": {
"title": "Generate Invitation Codes",
"description": "Batch generate invitation codes with role, email restriction, class binding, or expiry.",
"count": "Count",
"countDescription": "1-100 codes per batch",
"role": "Role",
"email": "Restrict to Email (optional)",
"emailDescription": "Leave empty for any email; if set, only this email can use the code",
"classId": "Class ID (optional)",
"classIdDescription": "When set, students will be auto-enrolled into this class on registration",
"expiresAt": "Expiry (optional)",
"expiresAtDescription": "Leave empty for permanent validity; supports ISO strings (e.g., 2026-12-31T23:59:59Z)",
"notes": "Notes (optional)",
"notesDescription": "Visible to admins only; useful for recording the batch purpose",
"submit": "Generate Codes",
"generating": "Generating...",
"success": "Successfully generated {count} invitation codes",
"failed": "Failed to generate"
},
"columns": {
"code": "Code",
"role": "Role",
"email": "Restricted Email",
"classId": "Class",
"expiresAt": "Expires At",
"status": "Status",
"createdAt": "Created At",
"usedBy": "Used By",
"usedAt": "Used At",
"actions": "Actions"
},
"status": {
"unused": "Unused",
"used": "Used",
"expired": "Expired"
},
"actions": {
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete",
"deleteConfirmTitle": "Delete this invitation code?",
"deleteConfirmDescription": "Used codes cannot be deleted (kept as audit records). Only unused or expired codes can be deleted.",
"cancel": "Cancel",
"deleting": "Deleting...",
"confirmDelete": "Confirm Delete",
"deleted": "Invitation code deleted",
"deleteFailedUsed": "Used invitation codes cannot be deleted"
},
"empty": {
"title": "No invitation codes yet",
"description": "Click \"Generate Codes\" to create a batch, or instruct users to enter a code during registration."
},
"generatedList": {
"title": "Generated Codes (This Batch)",
"description": "Save these code strings securely. After closing this dialog, the full codes will no longer be displayed.",
"copyAll": "Copy All",
"close": "Close",
"batchId": "Batch ID"
},
"errors": {
"loadFailed": "Failed to load invitation codes",
"permissionDenied": "You do not have permission to access this page"
}
}

View File

@@ -1,6 +1,12 @@
{
"title": "Leave Request",
"title.parent": "Online Leave",
"title.teacher": "Leave Approval",
"title.student": "My Leave Requests",
"description": "Submit a leave request for your child.",
"description.parent": "Submit a leave request for your child. Attendance is auto-synced after homeroom teacher approval.",
"description.teacher": "View and approve leave requests from students in your classes. Approved leaves automatically mark attendance as 'excused'.",
"description.student": "Submit your own leave request. It takes effect after homeroom teacher approval.",
"backToDashboard": "Back to Dashboard",
"onlineLeave": "Online Leave Request",
"comingSoon": "Coming soon",
@@ -8,5 +14,68 @@
"contactOptions": "Contact options",
"callOffice": "Call the school office during working hours",
"sendMessage": "Send a message to the homeroom teacher via the Messages page",
"goToMessages": "Go to Messages"
}
"goToMessages": "Go to Messages",
"types": {
"sick": "Sick Leave",
"personal": "Personal Leave",
"family": "Family Affairs",
"other": "Other"
},
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled"
},
"form": {
"student": "Student",
"studentPlaceholder": "Select your child",
"leaveType": "Leave Type",
"dateRange": "Leave Dates",
"reason": "Reason",
"reasonPlaceholder": "Briefly explain the reason for leave (max 500 chars)",
"submit": "Submit Request",
"submitting": "Submitting…"
},
"list": {
"reason": "Reason",
"reviewedBy": "Reviewed by {name} ({date})",
"reviewComment": "Review Comment",
"attendanceSynced": "Attendance synced"
},
"review": {
"title": "Review Leave Request",
"description": "{name} · {className} · {dateRange}",
"approve": "Approve",
"reject": "Reject",
"cancel": "Cancel",
"commentLabel": "Review Comment",
"commentPlaceholder": "Enter review comment (required for rejection, optional for approval)",
"openButton": "Review"
},
"errors": {
"invalidForm": "The form has errors, please check and retry",
"noRelation": "You have no parent-child relation with this student",
"classMismatch": "Selected class does not match the student's current class",
"overlappingLeave": "An active leave request already exists in this date range",
"reviewCommentRequired": "Review comment is required when rejecting",
"notFound": "Leave request not found or no access",
"alreadyReviewed": "This leave request has already been reviewed",
"notOwner": "You can only cancel your own leave requests",
"cannotCancel": "Current status does not allow cancellation (only pending can be cancelled)"
},
"messages": {
"created": "Leave request submitted, waiting for homeroom teacher approval",
"approved": "Leave request approved, attendance synced",
"rejected": "Leave request rejected",
"cancelled": "Leave request cancelled"
},
"empty": {
"parentTitle": "No leave records",
"parentDesc": "You have not submitted any leave requests for your children",
"teacherTitle": "No leave requests",
"teacherDesc": "No pending or reviewed leave requests from your classes",
"studentTitle": "No leave records",
"studentDesc": "You have not submitted any leave requests"
}
}

View File

@@ -34,7 +34,10 @@
"unsaved": "Unsaved",
"saved": "Saved",
"draft": "Draft",
"submitted": "Pending Review",
"approved": "Approved",
"published": "Published",
"rejected": "Rejected",
"archived": "Archived",
"publishedAsHomework": "Published as Homework"
},
@@ -235,6 +238,7 @@
"typeLabel": "Question Type",
"stemLabel": "Question Stem",
"optionsLabel": "Options (check correct answer)",
"optionLabel": "Option {index}",
"addOption": "+ Add Option",
"correctAnswer": "Correct Answer",
"correct": "True",
@@ -395,5 +399,207 @@
"confirm": {
"archive": "Archive this lesson plan?",
"archiveTitle": "Confirm Archive"
},
"calendar": {
"title": "Calendar View",
"week": "Week",
"month": "Month",
"today": "Today",
"noEvents": "No events",
"loading": "Loading calendar...",
"error": "Failed to load calendar events",
"eventType": {
"created": "Created",
"updated": "Updated",
"version_saved": "Version Saved",
"submitted": "Submitted",
"published": "Published",
"reviewed": "Reviewed"
}
},
"review": {
"title": "Review Workflow",
"submit": "Submit for Review",
"approve": "Approve",
"reject": "Reject",
"pending": "Pending Review",
"approved": "Approved",
"rejected": "Rejected",
"submitConfirm": "Are you sure you want to submit this lesson plan for review?",
"submitSuccess": "Lesson plan submitted for review",
"submitFailed": "Failed to submit for review",
"approveConfirm": "Are you sure you want to approve this lesson plan?",
"approveSuccess": "Lesson plan approved",
"approveFailed": "Failed to approve lesson plan",
"rejectConfirm": "Are you sure you want to reject this lesson plan?",
"rejectSuccess": "Lesson plan rejected",
"rejectFailed": "Failed to reject lesson plan",
"records": "Review Records",
"noRecords": "No review records",
"reviewer": "Reviewer",
"decision": "Decision",
"comment": "Comment",
"time": "Time",
"pendingReviews": "Pending Reviews",
"noPending": "No pending reviews"
},
"comment": {
"title": "Comments",
"add": "Add Comment",
"edit": "Edit",
"delete": "Delete",
"reply": "Reply",
"resolve": "Resolve",
"resolved": "Resolved",
"reopen": "Reopen",
"noComments": "No comments yet",
"placeholder": "Write a comment...",
"blockComment": "Block Comment",
"deleteConfirm": "Are you sure you want to delete this comment?",
"deleteSuccess": "Comment deleted",
"deleteFailed": "Failed to delete comment",
"createSuccess": "Comment added",
"createFailed": "Failed to add comment",
"updateSuccess": "Comment updated",
"updateFailed": "Failed to update comment",
"resolveSuccess": "Comment resolved",
"resolveFailed": "Failed to resolve comment"
},
"formative": {
"title": "Formative Assessment",
"add": "Add Interaction",
"edit": "Edit",
"delete": "Delete",
"noItems": "No formative assessment items",
"interactionType": {
"question": "Question",
"poll": "Poll",
"discussion": "Discussion",
"reflection": "Reflection"
},
"question": "Question",
"options": "Options",
"correctAnswer": "Correct Answer",
"pollQuestion": "Poll Question",
"pollOptions": "Poll Options",
"discussionTopic": "Discussion Topic",
"reflectionPrompt": "Reflection Prompt",
"responses": "Responses",
"noResponses": "No responses yet",
"deleteConfirm": "Are you sure you want to delete this interaction?",
"deleteSuccess": "Interaction deleted",
"deleteFailed": "Failed to delete interaction",
"createSuccess": "Interaction added",
"createFailed": "Failed to add interaction",
"updateSuccess": "Interaction updated",
"updateFailed": "Failed to update interaction",
"recordSuccess": "Response recorded",
"recordFailed": "Failed to record response"
},
"attachment": {
"title": "Attachments",
"add": "Add Attachment",
"edit": "Edit",
"delete": "Delete",
"noAttachments": "No attachments",
"name": "Name",
"type": "Type",
"size": "Size",
"url": "URL",
"upload": "Upload",
"download": "Download",
"deleteConfirm": "Are you sure you want to delete this attachment?",
"deleteSuccess": "Attachment deleted",
"deleteFailed": "Failed to delete attachment",
"createSuccess": "Attachment added",
"createFailed": "Failed to add attachment",
"updateSuccess": "Attachment updated",
"updateFailed": "Failed to update attachment"
},
"substitute": {
"title": "Substitute Teacher",
"add": "Add Assignment",
"edit": "Edit",
"delete": "Delete",
"noAssignments": "No substitute assignments",
"originalTeacher": "Original Teacher",
"substituteTeacher": "Substitute Teacher",
"startDate": "Start Date",
"endDate": "End Date",
"classes": "Classes",
"subject": "Subject",
"deleteConfirm": "Are you sure you want to delete this substitute assignment?",
"deleteSuccess": "Substitute assignment deleted",
"deleteFailed": "Failed to delete substitute assignment",
"createSuccess": "Substitute assignment created",
"createFailed": "Failed to create substitute assignment",
"updateSuccess": "Substitute assignment updated",
"updateFailed": "Failed to update substitute assignment"
},
"evaluation": {
"title": "AI Evaluation",
"generate": "Generate Evaluation",
"regenerate": "Regenerate",
"score": "Score",
"suggestions": "Suggestions",
"differentiation": "Differentiation Strategies",
"noEvaluation": "No AI evaluation yet",
"loading": "Generating AI evaluation...",
"generateConfirm": "Generate AI evaluation for this lesson plan?",
"generateSuccess": "AI evaluation generated",
"generateFailed": "Failed to generate AI evaluation",
"strengths": "Strengths",
"weaknesses": "Areas for Improvement",
"recommendations": "Recommendations"
},
"analytics": {
"title": "Analytics Dashboard",
"teacherInvestment": "Teacher Investment",
"standardsCoverage": "Standards Coverage",
"globalStats": "Global Statistics",
"totalTeachers": "Total Teachers",
"totalPlans": "Total Lesson Plans",
"publishedPlans": "Published Plans",
"draftPlans": "Draft Plans",
"archivedPlans": "Archived Plans",
"newPlans": "New Plans",
"editDuration": "Edit Duration (min)",
"savedVersions": "Saved Versions",
"submittedPlans": "Submitted Plans",
"coverage": "Coverage",
"noData": "No data available",
"loading": "Loading analytics...",
"heatmap": "Coverage Heatmap",
"subject": "Subject",
"grade": "Grade"
},
"standards": {
"title": "Standards Alignment",
"add": "Add Standard",
"edit": "Edit",
"delete": "Delete",
"search": "Search standards...",
"noStandards": "No standards found",
"code": "Code",
"description": "Description",
"subject": "Subject",
"grade": "Grade",
"parent": "Parent Standard",
"linkToPlan": "Link to Lesson Plan",
"unlink": "Unlink",
"linkedStandards": "Linked Standards",
"availableStandards": "Available Standards",
"tree": "Standards Tree",
"deleteConfirm": "Are you sure you want to delete this standard?",
"deleteSuccess": "Standard deleted",
"deleteFailed": "Failed to delete standard",
"createSuccess": "Standard created",
"createFailed": "Failed to create standard",
"updateSuccess": "Standard updated",
"updateFailed": "Failed to update standard",
"linkSuccess": "Standard linked to lesson plan",
"linkFailed": "Failed to link standard",
"unlinkSuccess": "Standard unlinked from lesson plan",
"unlinkFailed": "Failed to unlink standard"
}
}

View File

@@ -4,11 +4,13 @@
"detail": "Message",
"compose": "Compose Message",
"reply": "Reply",
"newMessage": "New Message"
"newMessage": "New Message",
"groupCompose": "Group Message"
},
"description": {
"list": "Manage your inbox and stay updated with notifications.",
"compose": "Send a message to another user."
"compose": "Send a message to another user.",
"groupCompose": "Send a message to all parents of a class."
},
"tabs": {
"inbox": "Inbox",
@@ -25,7 +27,11 @@
"star": "Star",
"unstar": "Unstar",
"saveDraft": "Save Draft",
"deleteDraft": "Delete Draft"
"deleteDraft": "Delete Draft",
"recall": "Recall",
"report": "Report",
"block": "Block",
"unblock": "Unblock"
},
"form": {
"to": "To",
@@ -34,13 +40,17 @@
"subjectPlaceholder": "Message subject",
"content": "Content",
"contentPlaceholder": "Write your message...",
"selectRecipient": "Please select a recipient"
"selectRecipient": "Please select a recipient",
"class": "Class",
"classPlaceholder": "Select a class",
"selectClass": "Please select a class"
},
"status": {
"new": "New",
"read": "Read",
"sent": "Sent",
"unread": "Unread"
"unread": "Unread",
"recalled": "Recalled"
},
"meta": {
"from": "From",
@@ -53,6 +63,71 @@
"placeholder": "Search messages by subject or content...",
"noResults": "No matching messages found"
},
"pagination": {
"nav": "Message pagination",
"previous": "Previous",
"next": "Next",
"page": "Page {current} / {total}"
},
"drafts": {
"title": "Drafts",
"resume": "Resume editing",
"delete": "Delete draft",
"noContent": "(no content)",
"updatedAt": "Updated {date}",
"versionConflict": "Draft was updated on another device. Synced to latest version.",
"syncedFromOtherDevice": "Draft synced from another device"
},
"templates": {
"title": "Quick Reply Templates",
"insert": "Insert template",
"manage": "Manage templates",
"create": "New template",
"edit": "Edit",
"delete": "Delete",
"empty": "No templates",
"emptyDesc": "Create common reply templates to improve communication efficiency.",
"titleField": "Template title",
"titlePlaceholder": "e.g., Acknowledged",
"contentField": "Template content",
"contentPlaceholder": "e.g., Acknowledged, thank you for your feedback.",
"sortOrder": "Sort order (lower comes first)",
"saved": "Template saved",
"deleted": "Template deleted",
"deleteConfirm": "Are you sure you want to delete this template?"
},
"report": {
"title": "Report Message",
"description": "Report inappropriate content. An administrator will review it.",
"reason": "Reason",
"reasonSpam": "Spam",
"reasonHarassment": "Harassment",
"reasonInappropriate": "Inappropriate content",
"reasonOther": "Other",
"descriptionField": "Description (optional)",
"descriptionPlaceholder": "Please provide more details...",
"confirm": "Confirm Report",
"cancel": "Cancel"
},
"block": {
"title": "Block User",
"confirm": "Are you sure you want to block this user? You will not be able to send messages to each other.",
"list": "Blocked List",
"empty": "No blocked users"
},
"attachments": {
"add": "Add Attachment",
"remove": "Remove Attachment",
"title": "Attachments ({count})",
"download": "Download Attachment",
"empty": "No attachments"
},
"thread": {
"title": "Conversation",
"replyCount": "{count} replies",
"noReplies": "No replies yet",
"you": "You"
},
"empty": {
"inboxEmpty": "Inbox is empty",
"inboxEmptyDesc": "You have no incoming messages yet.",
@@ -75,7 +150,26 @@
"selectRecipient": "Please select a recipient",
"draftSaved": "Draft saved",
"draftDeleted": "Draft deleted",
"starToggled": "Star status updated"
"starToggled": "Star status updated",
"recalled": "Message recalled",
"recallFailed": "Failed to recall message",
"recallExpired": "Recall window (2 minutes) has expired",
"recallConfirm": "Are you sure you want to recall this message?",
"groupSent": "Group message sent to {count} parents",
"groupNoRecipients": "No recipients found in this class",
"groupNotAuthorized": "You are not authorized to send group messages to this class",
"groupSendFailed": "Failed to send group message",
"reported": "Message reported",
"alreadyReported": "You have already reported this message",
"reportFailed": "Failed to report message",
"reportSelf": "Cannot report your own message",
"blocked": "User blocked",
"alreadyBlocked": "User is already blocked",
"blockSelf": "Cannot block yourself",
"blockFailed": "Failed to block user",
"unblocked": "User unblocked",
"unblockFailed": "Failed to unblock user",
"blockedSendHint": "You have been blocked and cannot send messages"
},
"notification": {
"messageTitle": "New message: {subject}",
@@ -84,6 +178,8 @@
"error": {
"loadFailed": "Failed to load messages",
"loadFailedDesc": "Sorry, an unexpected error occurred while loading messages. Please try again later.",
"retry": "Retry"
"retry": "Retry",
"boundaryTitle": "Message section failed to load",
"boundaryDescription": "An error occurred while loading message data. Please retry."
}
}

View File

@@ -16,6 +16,8 @@
"users": "Users",
"userList": "User List",
"importUsers": "Import Users",
"roles": "Roles",
"permissions": "Permissions",
"teaching": "Teaching",
"coursePlans": "Course Plans",
"electives": "Electives",

View File

@@ -18,7 +18,8 @@
"message": "Message",
"announcement": "Announcement",
"homework": "Homework",
"grade": "Grade"
"grade": "Grade",
"diagnostic": "Diagnostic"
},
"filter": {
"all": "All"

View File

@@ -0,0 +1,22 @@
{
"childDetail": {
"defaultName": "Child",
"tabs": {
"overview": "Overview",
"homework": "Homework",
"grades": "Grades",
"exams": "Exams",
"schedule": "Schedule",
"attendance": "Attendance",
"diagnostic": "Diagnostic"
},
"tabAriaLabel": "{label} tab",
"subjectAnalysis": "Subject Analysis",
"attendanceHint": "Attendance details are available on the <link>Attendance page</link>.",
"diagnosticHint": "Diagnostic reports will be available here once published by the school.",
"contactTeacher": "Contact Teacher",
"contactTeacherAria": "Contact teacher about {name}",
"switchChild": "Switch child",
"viewDetailsAria": "View {name}'s details"
}
}

View File

@@ -38,7 +38,9 @@
"true": "True",
"false": "False",
"textPlaceholder": "Enter your answer...",
"empty": "No questions in this session"
"empty": "No questions in this session",
"retry": "Retry",
"submitFailedDescription": "Submit failed. Please check your network and retry."
},
"result": {
"title": "Practice Complete",
@@ -56,7 +58,6 @@
"createFailed": "Failed to create",
"selectKnowledgePoint": "Please select at least one knowledge point",
"selectWeakKnowledgePoint": "Please select at least one weak knowledge point",
"aiRecommendedReason": "Student-initiated AI recommended practice",
"submitted": "Submitted",
"submitFailed": "Submit failed",
"completed": "Practice completed",
@@ -71,7 +72,7 @@
"accuracy": "Accuracy"
},
"types": {
"error_variant": "Error Variant",
"error_variant": "Error Review",
"knowledge_point": "Knowledge Point",
"weak_chapter": "Weak Chapter",
"ai_recommended": "AI Recommended"
@@ -159,5 +160,69 @@
"title": "Class Practice Comparison",
"description": "Sorted by participation rate to identify active and underperforming classes"
}
},
"parent": {
"title": "Child's Targeted Practice",
"description": "View your child's practice records and stats to understand learning progress",
"childSelector": "Select Child",
"noChild": "No linked children",
"noChildDescription": "You haven't linked any children. Please contact the school administrator.",
"stats": "Practice Stats",
"history": "Practice History",
"noChildSelected": "Please select a child to view practice data"
},
"errors": {
"sessionNotFound": "Practice session not found or access denied",
"sessionEnded": "Practice session has ended",
"answerNotFound": "Answer record not found",
"answerAlreadySubmitted": "This question has been answered",
"questionNotFound": "Question not found",
"sessionNotComplete": "Some questions are unanswered. Cannot complete the practice.",
"invalidFormat": "Invalid format. JSON field is required.",
"validationFailed": "Validation failed",
"noQuestionsFound": "No questions match the criteria. Try different filters.",
"fetchSessionsFailed": "Failed to fetch practice sessions",
"fetchDetailFailed": "Failed to fetch practice detail",
"fetchStatsFailed": "Failed to fetch practice stats",
"forbidden": "Access denied to this student's practice data",
"loadFailed": "Load failed",
"loadFailedDescription": "Please refresh the page or contact an administrator to check data access permissions.",
"retry": "Retry",
"pageErrorPractice": "An error occurred while loading practice analytics data",
"pageErrorGrade": "An error occurred while loading grade practice data",
"pageErrorParent": "An error occurred while loading child practice data",
"pageErrorTitlePractice": "Practice Analytics",
"pageErrorTitleGrade": "Grade Practice Overview",
"pageErrorTitleParent": "Child's Practice",
"session_not_found": "Practice session not found or access denied",
"session_ended": "Practice session has ended",
"answer_not_found": "Answer record not found",
"answer_already_submitted": "This question has been answered",
"question_not_found": "Question not found",
"session_not_complete": "Some questions are unanswered. Cannot complete the practice.",
"no_questions_found": "No questions match the criteria. Try different filters.",
"forbidden_scope": "Access denied to this student's practice data",
"invalid_input": "Invalid format. JSON field is required.",
"validation_error": "Validation failed",
"invalid_source_meta": "Source metadata structure does not match the practice type"
},
"messages": {
"sessionCreated": "Practice session created with {count} questions",
"answerSubmitted": "Answer submitted",
"answerCorrect": "Correct answer",
"answerIncorrect": "Incorrect answer",
"answerSkipped": "Question skipped",
"sessionCompleted": "Practice completed",
"sessionAbandoned": "Practice abandoned"
},
"reasons": {
"student_initiated": "Student-initiated AI recommended practice",
"teacher_assigned": "Teacher assigned",
"parent_suggested": "Parent suggested"
},
"classFilter": {
"all": "All Classes",
"selectClass": "Select Class",
"sessionCount": "{count, plural, =0 {No sessions} =1 {# session} other {# sessions}}"
}
}

View File

@@ -0,0 +1,156 @@
{
"title": "Question Bank",
"subtitle": "Manage your question repository for exams and assignments.",
"addQuestion": "Add Question",
"search": {
"placeholder": "Search questions..."
},
"filters": {
"type": "Type",
"typeAll": "All Types",
"difficulty": "Difficulty",
"difficultyAll": "Any Difficulty",
"difficulty1": "Easy (1)",
"difficulty2": "Easy-Med (2)",
"difficulty3": "Medium (3)",
"difficulty4": "Med-Hard (4)",
"difficulty5": "Hard (5)",
"knowledgePoint": "Knowledge Point",
"knowledgePointAll": "All Knowledge Points",
"textbook": "Textbook",
"textbookAll": "All Textbooks",
"chapter": "Chapter",
"chapterAll": "All Chapters",
"clear": "Clear filters"
},
"table": {
"type": "Type",
"content": "Content",
"difficulty": "Difficulty",
"knowledgePoints": "Knowledge Points",
"created": "Created",
"actions": "Actions",
"noResults": "No results.",
"selected": "{count} of {total} row(s) selected.",
"previous": "Previous",
"next": "Next"
},
"type": {
"single_choice": "Single Choice",
"multiple_choice": "Multiple Choice",
"judgment": "True/False",
"text": "Short Answer",
"composite": "Composite"
},
"difficulty": {
"1": "Easy",
"2": "Easy-Med",
"3": "Medium",
"4": "Med-Hard",
"5": "Hard"
},
"empty": {
"withFilters": "No questions match your filters",
"withoutFilters": "No questions yet",
"withFiltersDesc": "Try clearing filters or adjusting keywords.",
"withoutFiltersDesc": "Create your first question to start building exams and assignments."
},
"dialog": {
"createTitle": "Create New Question",
"editTitle": "Edit Question",
"createDesc": "Add a new question to the bank. Fill in the details below.",
"editDesc": "Update question details.",
"questionType": "Question Type",
"selectType": "Select type",
"difficulty": "Difficulty (1-5)",
"selectDifficulty": "Select difficulty",
"questionContent": "Question Content",
"contentPlaceholder": "Enter the question text here...",
"contentDescription": "Supports basic text. Rich text editor coming soon.",
"knowledgePoints": "Knowledge Points",
"knowledgePointsOptional": "Optional",
"knowledgePointsSelected": "{count} selected",
"searchKnowledgePoints": "Search knowledge points...",
"noKnowledgePoints": "No knowledge points found.",
"loading": "Loading...",
"options": "Options",
"addOption": "Add Option",
"optionPlaceholder": "Option {index}",
"markCorrect": "Mark correct",
"cancel": "Cancel",
"create": "Create Question",
"update": "Update Question",
"creating": "Creating...",
"updating": "Updating..."
},
"actions": {
"menuLabel": "Open menu",
"actions": "Actions",
"copyId": "Copy ID",
"viewDetails": "View Details",
"edit": "Edit",
"delete": "Delete",
"copyIdSuccess": "Question ID copied to clipboard",
"copyIdFailed": "Failed to copy question ID",
"deleteSuccess": "Question deleted successfully",
"deleteFailed": "Failed to delete question",
"deleteConfirmTitle": "Are you absolutely sure?",
"deleteConfirmDesc": "This action cannot be undone. This will permanently delete the question and remove it from our servers.",
"deleteConfirmCancel": "Cancel",
"deleteConfirmAction": "Delete",
"deleting": "Deleting...",
"detailsTitle": "Question Details",
"detailsId": "ID: {id}",
"detailsType": "Type",
"detailsDifficulty": "Difficulty",
"detailsContent": "Content",
"detailsAuthor": "Author",
"detailsTags": "Tags",
"detailsUnknown": "Unknown"
},
"error": {
"loadFailed": "Failed to load question bank",
"loadFailedDesc": "Please try again or contact the administrator.",
"retry": "Retry",
"invalidFormat": "Invalid question content format",
"invalidSubmission": "Invalid submission format. Expected JSON.",
"validationFailed": "Validation failed",
"missingId": "Missing question id",
"operationFailed": "Operation failed",
"unexpected": "Unexpected error",
"createdSuccess": "Question created successfully",
"updatedSuccess": "Question updated successfully"
},
"noPermission": {
"title": "No Permission",
"description": "You do not have permission to perform this action."
},
"batch": {
"selected": "{count} selected",
"delete": "Batch Delete",
"clear": "Clear Selection",
"cancel": "Cancel",
"deleteConfirmTitle": "Confirm Batch Delete?",
"deleteConfirmDesc": "This will delete {count} question(s) and their sub-questions. This action cannot be undone.",
"deleteConfirmAction": "Confirm Delete",
"deleting": "Deleting...",
"deleteSuccess": "Successfully deleted {count} question(s)",
"deleteFailed": "Batch delete failed"
},
"importExport": {
"export": "Export",
"import": "Import",
"exporting": "Exporting...",
"importing": "Importing...",
"exportSuccess": "Successfully exported {count} question(s)",
"exportFailed": "Export failed",
"importSuccess": "Successfully imported {count} question(s)",
"importFailed": "Import failed",
"invalidFile": "Invalid file format",
"readFailed": "Failed to read file",
"confirmTitle": "Confirm Import",
"confirmDesc": "Please confirm the following is the question data to import:",
"confirmImport": "Confirm Import",
"cancel": "Cancel"
}
}

View File

@@ -0,0 +1,318 @@
{
"roles": {
"title": "Role Management",
"description": "Create custom roles and manage permission assignments.",
"detail": "Role Detail",
"create": "Create Role",
"edit": "Edit Role",
"delete": "Delete Role",
"name": "Role Name",
"description": "Description",
"type": "Type",
"status": "Status",
"system": "System",
"custom": "Custom",
"enabled": "Enabled",
"disabled": "Disabled",
"permissions": "Permissions",
"users": "Users",
"updated": "Updated",
"locked": "Locked",
"adminLocked": "The admin role is fully locked and cannot be modified.",
"listTitle": "Roles ({count})",
"emptyTitle": "No roles yet",
"emptyDescription": "Create your first custom role to get started.",
"editButton": "Edit role",
"createTitle": "Create new role",
"editRoleTitle": "Edit role: {name}",
"createDescription": "Create a custom role. You can assign permissions to it after creation. Role names must be lowercase letters, digits, and underscores.",
"editDescription": "Update the role name or description. Permission assignments are managed on the role detail page.",
"namePlaceholder": "e.g. teaching_assistant",
"namePatternTitle": "Lowercase letters, digits, and underscores only",
"adminNameLocked": "The admin role name cannot be changed.",
"descriptionLabel": "Description (optional)",
"descriptionPlaceholder": "What is this role for?",
"deleteConfirmTitle": "Delete role \"{name}\"?",
"deleteConfirmDescription": "This will remove the role and revoke it from {count} user(s). Users assigned only to this role will lose all access. This action cannot be undone.",
"assignTitle": "Assign roles",
"assignDescription": "Select roles for <strong>{name}</strong>. The user may need to sign in again for changes to take effect.",
"noEnabledRoles": "No enabled roles available. Create or enable a role first.",
"disabledAssignedLabel": "Currently assigned but disabled (will be removed on save):",
"disabledLabel": "disabled",
"saveRoles": "Save roles",
"openMenu": "Open menu",
"tableAriaLabel": "Role list",
"tableCaption": "{count} role(s) total",
"assignListAriaLabel": "Assign roles to {name}"
},
"permissions": {
"title": "Permission Catalog",
"description": "All permission points defined in the system, grouped by module.",
"countLabel": "{count} permissions",
"roleCount": "{count} role(s)",
"matrixTitle": "Permissions — {roleName}",
"selectedCount": "{count} selected",
"impactNotice": "Permission changes to this role will affect {count} user(s).",
"searchPlaceholder": "Search by permission name or key...",
"searchAriaLabel": "Search permissions",
"searchNoResults": "No matching permissions",
"expandGroup": "Expand {name} group",
"collapseGroup": "Collapse {name} group",
"catalogAriaLabel": "Permission catalog",
"group": {
"exam": "Exams",
"homework": "Homework",
"question": "Questions",
"textbook": "Textbooks",
"class": "Classes",
"school": "School",
"user": "User",
"ai": "AI",
"settings": "Settings",
"audit": "Audit",
"announcement": "Announcements",
"grade_record": "Grade Records",
"file": "Files",
"course_plan": "Course Plans",
"attendance": "Attendance",
"message": "Messages",
"scheduling": "Scheduling",
"elective": "Electives",
"diagnostic": "Diagnostics",
"lesson_plan": "Lesson Plans",
"dashboard": "Dashboards",
"error_book": "Error Book",
"adaptive_practice": "Adaptive Practice",
"rbac": "Role & Permission Management"
},
"exam": {
"create": "Create Exam",
"create.desc": "Allows creating new exams",
"read": "Read Exam",
"read.desc": "Allows viewing exam list and details",
"update": "Update Exam",
"update.desc": "Allows modifying exam information",
"delete": "Delete Exam",
"delete.desc": "Allows deleting exams",
"duplicate": "Duplicate Exam",
"duplicate.desc": "Allows duplicating existing exams",
"publish": "Publish Exam",
"publish.desc": "Allows publishing exams for students to take",
"ai_generate": "AI Generate Exam",
"ai_generate.desc": "Allows using AI to generate exam content",
"submit": "Submit Exam",
"submit.desc": "Allows submitting exam responses",
"proctor": "Proctor Exam",
"proctor.desc": "Allows performing proctoring operations",
"proctor_read": "Read Proctoring",
"proctor_read.desc": "Allows viewing proctoring information"
},
"homework": {
"create": "Create Homework",
"create.desc": "Allows creating new homework",
"grade": "Grade Homework",
"grade.desc": "Allows grading student homework",
"submit": "Submit Homework",
"submit.desc": "Allows submitting homework"
},
"question": {
"create": "Create Question",
"create.desc": "Allows creating new questions",
"read": "Read Question",
"read.desc": "Allows viewing question list and details",
"update": "Update Question",
"update.desc": "Allows modifying question information",
"delete": "Delete Question",
"delete.desc": "Allows deleting questions"
},
"textbook": {
"create": "Create Textbook",
"create.desc": "Allows creating new textbooks",
"read": "Read Textbook",
"read.desc": "Allows viewing textbook list and details",
"update": "Update Textbook",
"update.desc": "Allows modifying textbook information",
"delete": "Delete Textbook",
"delete.desc": "Allows deleting textbooks"
},
"class": {
"create": "Create Class",
"create.desc": "Allows creating new classes",
"read": "Read Class",
"read.desc": "Allows viewing class list and details",
"update": "Update Class",
"update.desc": "Allows modifying class information",
"delete": "Delete Class",
"delete.desc": "Allows deleting classes",
"enroll": "Enroll Students",
"enroll.desc": "Allows managing student enrollment in classes",
"schedule": "Class Scheduling",
"schedule.desc": "Allows managing class schedules"
},
"school": {
"manage": "Manage School",
"manage.desc": "Allows managing school information",
"grade_manage": "Manage Grades",
"grade_manage.desc": "Allows managing grade information",
"user_manage": "Manage Users",
"user_manage.desc": "Allows managing system users"
},
"user": {
"profile_update": "Update Profile",
"profile_update.desc": "Allows updating user profile"
},
"ai": {
"chat": "AI Chat",
"chat.desc": "Allows using AI chat feature",
"configure": "AI Configure",
"configure.desc": "Allows configuring AI parameters"
},
"settings": {
"admin": "Admin Settings",
"admin.desc": "Allows managing system settings"
},
"audit": {
"read": "Read Audit Logs",
"read.desc": "Allows viewing system audit logs"
},
"announcement": {
"manage": "Manage Announcements",
"manage.desc": "Allows creating, modifying, and deleting announcements",
"read": "Read Announcements",
"read.desc": "Allows viewing announcement content"
},
"grade_record": {
"manage": "Manage Grade Records",
"manage.desc": "Allows entering and modifying grade records",
"read": "Read Grade Records",
"read.desc": "Allows viewing grade records"
},
"file": {
"upload": "Upload File",
"upload.desc": "Allows uploading files",
"read": "Read File",
"read.desc": "Allows viewing files",
"delete": "Delete File",
"delete.desc": "Allows deleting files"
},
"course_plan": {
"manage": "Manage Course Plans",
"manage.desc": "Allows creating, modifying, and deleting course plans",
"read": "Read Course Plans",
"read.desc": "Allows viewing course plans"
},
"attendance": {
"manage": "Manage Attendance",
"manage.desc": "Allows entering and modifying attendance records",
"read": "Read Attendance",
"read.desc": "Allows viewing attendance records"
},
"leave_request": {
"create": "Submit Leave Request",
"create.desc": "Allows parents/students to submit online leave requests",
"read": "Read Leave Requests",
"read.desc": "Allows viewing own/children/managed-class leave requests",
"review": "Review Leave Requests",
"review.desc": "Allows homeroom teachers/admins to approve leave requests and auto-sync attendance"
},
"message": {
"send": "Send Message",
"send.desc": "Allows sending messages",
"read": "Read Message",
"read.desc": "Allows viewing messages",
"delete": "Delete Message",
"delete.desc": "Allows deleting messages"
},
"scheduling": {
"auto": "Auto Scheduling",
"auto.desc": "Allows running automatic scheduling",
"adjust": "Adjust Scheduling",
"adjust.desc": "Allows manually adjusting schedules"
},
"elective": {
"manage": "Manage Electives",
"manage.desc": "Allows managing elective settings",
"read": "Read Electives",
"read.desc": "Allows viewing elective information",
"select": "Select Elective",
"select.desc": "Allows students to select electives"
},
"diagnostic": {
"manage": "Manage Diagnostics",
"manage.desc": "Allows managing diagnostic tests",
"read": "Read Diagnostics",
"read.desc": "Allows viewing diagnostic results"
},
"lesson_plan": {
"create": "Create Lesson Plan",
"create.desc": "Allows creating new lesson plans",
"read": "Read Lesson Plan",
"read.desc": "Allows viewing lesson plan list and details",
"update": "Update Lesson Plan",
"update.desc": "Allows modifying lesson plan information",
"delete": "Delete Lesson Plan",
"delete.desc": "Allows deleting lesson plans",
"publish": "Publish Lesson Plan",
"publish.desc": "Allows publishing lesson plans"
},
"dashboard": {
"admin_read": "Read Admin Dashboard",
"admin_read.desc": "Allows viewing admin dashboard",
"teacher_read": "Read Teacher Dashboard",
"teacher_read.desc": "Allows viewing teacher dashboard",
"student_read": "Read Student Dashboard",
"student_read.desc": "Allows viewing student dashboard",
"parent_read": "Read Parent Dashboard",
"parent_read.desc": "Allows viewing parent dashboard"
},
"error_book": {
"read": "Read Error Book",
"read.desc": "Allows viewing error book",
"manage": "Manage Error Book",
"manage.desc": "Allows managing error records",
"analytics_read": "Read Error Analytics",
"analytics_read.desc": "Allows viewing error book analytics"
},
"adaptive_practice": {
"read": "Read Adaptive Practice",
"read.desc": "Allows viewing adaptive practice",
"manage": "Manage Adaptive Practice",
"manage.desc": "Allows managing adaptive practice"
},
"rbac": {
"role_create": "Create Role",
"role_create.desc": "Allows creating new roles",
"role_read": "Read Role",
"role_read.desc": "Allows viewing role list and details",
"role_update": "Update Role",
"role_update.desc": "Allows modifying role information",
"role_delete": "Delete Role",
"role_delete.desc": "Allows deleting roles",
"role_assign": "Assign Role",
"role_assign.desc": "Allows assigning roles to users",
"permission_read": "Read Permission",
"permission_read.desc": "Allows viewing permission catalog"
}
},
"actions": {
"save": "Save Changes",
"cancel": "Cancel",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"assignRoles": "Assign Roles",
"saving": "Saving...",
"deleting": "Deleting..."
},
"messages": {
"created": "Role created",
"updated": "Role updated",
"deleted": "Role deleted",
"permissionsUpdated": "Permissions updated",
"rolesAssigned": "Roles assigned. The user may need to sign in again for changes to take effect.",
"roleEnabled": "Role enabled",
"roleDisabled": "Role disabled",
"roleStatusUpdated": "Role status updated"
}
}

View File

@@ -40,7 +40,44 @@
"save": "Save Changes",
"saving": "Saving...",
"success": "Profile updated successfully",
"failure": "Failed to update profile"
"failure": "Failed to update profile",
"page": {
"title": "Profile",
"description": "Manage your personal and account information.",
"editProfile": "Edit Profile",
"personalInfo": {
"title": "Personal Information",
"description": "Basic personal details.",
"fullName": "Full Name",
"gender": "Gender",
"age": "Age",
"phone": "Phone",
"address": "Address"
},
"accountInfo": {
"title": "Account Information",
"description": "System account details.",
"email": "Email",
"role": "Role",
"memberSince": "Member Since",
"onboardedAt": "Onboarded At"
}
},
"studentOverview": {
"title": "Student Overview",
"description": "Your academic performance and schedule."
},
"teacherOverview": {
"title": "Teacher Overview",
"description": "Your teaching subjects and classes.",
"teachingSubjects": "Teaching Subjects",
"teachingSubjectsDesc": "Subjects you are currently assigned to teach.",
"noSubjects": "No subjects assigned yet.",
"teachingClasses": "Teaching Classes",
"teachingClassesDesc": "Classes you are currently managing.",
"noClasses": "No classes assigned yet.",
"view": "View"
}
},
"notifications": {
"title": "Notification Preferences",
@@ -211,11 +248,14 @@
"baseUrl": "API URL",
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
"baseUrlDesc": "Enter base URL without /chat/completions suffix.",
"baseUrlDescOllama": "Ollama local URL, defaults to http://localhost:11434/v1. Leave blank to use default.",
"model": "Model",
"modelPlaceholder": "gpt-4o-mini",
"apiKey": "API Key",
"apiKeyPlaceholder": "Paste new key to replace",
"apiKeyDesc": "Existing key won't be displayed. Leave blank to keep current.",
"apiKeyPlaceholderOllama": "Not required for local deployment, leave blank",
"apiKeyDescOllama": "Ollama local deployment does not require an API key. Enter OLLAMA_API_KEY if configured.",
"setDefault": "Set as default",
"visibility": "Visibility",
"visibilityDesc": "Public providers are published by admins and available to everyone; private providers are only visible to the creator.",
@@ -275,6 +315,19 @@
"sectionLoadFailed": "This section failed to load",
"sectionLoadFailedDesc": "Please try again later."
},
"brand": {
"title": "Brand Configuration",
"description": "School brand information displayed on login/register pages.",
"schoolName": "Brand Name",
"logoUrl": "Logo URL",
"logoUrlDescription": "Optional. Falls back to default icon when empty. Square PNG/SVG recommended.",
"testimonialQuote": "Testimonial Quote",
"testimonialAuthor": "Testimonial Author",
"save": "Save Brand Configuration",
"saveSuccess": "Brand configuration saved",
"saveFailed": "Failed to save brand configuration",
"loadFailed": "Failed to load brand configuration"
},
"admin": {
"title": "System Settings",
"description": "Manage system basics and runtime parameters.",
@@ -329,42 +382,5 @@
"saveSuccess": "Settings saved",
"saveFailure": "Failed to save settings",
"loadFailure": "Failed to load system settings"
},
"profilePage": {
"title": "Profile",
"description": "Manage your personal and account information.",
"editProfile": "Edit Profile",
"personalInfo": {
"title": "Personal Information",
"description": "Basic personal details.",
"fullName": "Full Name",
"gender": "Gender",
"age": "Age",
"phone": "Phone",
"address": "Address"
},
"accountInfo": {
"title": "Account Information",
"description": "System account details.",
"email": "Email",
"role": "Role",
"memberSince": "Member Since",
"onboardedAt": "Onboarded At"
},
"studentOverview": {
"title": "Student Overview",
"description": "Your academic performance and schedule."
},
"teacherOverview": {
"title": "Teacher Overview",
"description": "Your teaching subjects and classes.",
"teachingSubjects": "Teaching Subjects",
"teachingSubjectsDesc": "Subjects you are currently assigned to teach.",
"noSubjects": "No subjects assigned yet.",
"teachingClasses": "Teaching Classes",
"teachingClassesDesc": "Classes you are currently managing.",
"noClasses": "No classes assigned yet.",
"view": "View"
}
}
}

View File

@@ -66,6 +66,12 @@
"title": "My Diagnostic",
"description": "Your knowledge point mastery analysis and diagnostic reports."
},
"studyPath": {
"title": "AI Learning Path",
"description": "Based on your learning data, AI generates a personalized learning path to help you master weak knowledge points step by step.",
"noUser": "User not found",
"noUserDesc": "Please sign in as a student to generate a learning path."
},
"error": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try again.",

View File

@@ -164,6 +164,10 @@
"grade": {
"grade1": "Grade 1",
"grade2": "Grade 2",
"grade3": "Grade 3",
"grade4": "Grade 4",
"grade5": "Grade 5",
"grade6": "Grade 6",
"grade7": "Grade 7",
"grade8": "Grade 8",
"grade9": "Grade 9",
@@ -215,7 +219,9 @@
"prerequisiteCreated": "Prerequisite added",
"prerequisiteCreateFailed": "Failed to add prerequisite",
"prerequisiteDeleted": "Prerequisite removed",
"prerequisiteDeleteFailed": "Failed to remove prerequisite"
"prerequisiteDeleteFailed": "Failed to remove prerequisite",
"notFound": "Textbook not found or access denied",
"noClassMasteryPermission": "No permission to view class mastery data"
},
"graph": {
"viewMode": {
@@ -231,6 +237,7 @@
},
"detail": {
"title": "Knowledge Point Details",
"close": "Close",
"noDescription": "No description",
"viewAllQuestions": "View all questions",
"editPrerequisite": "Edit prerequisites",
@@ -246,6 +253,8 @@
"totalQuestions": "Total questions",
"prerequisiteAdded": "Prerequisite added",
"prerequisiteRemoved": "Prerequisite removed",
"prerequisiteAddFailed": "Failed to add prerequisite",
"prerequisiteRemoveFailed": "Failed to remove prerequisite",
"cancel": "Cancel",
"confirm": "Confirm",
"saving": "Saving..."

View File

@@ -1,6 +1,43 @@
{
"title": "User Management",
"description": "Manage all system users.",
"adminDescription": "Manage all system users, including viewing, searching, and deleting.",
"importButton": "Batch Import",
"searchPlaceholder": "Search by name or email...",
"allRoles": "All Roles",
"search": "Search",
"reset": "Reset",
"emptyTitle": "No users yet",
"emptyFilteredDescription": "No matching users. Please adjust your search criteria.",
"emptyDescription": "There are no users in the system yet. Click Batch Import to create some.",
"unassigned": "Unassigned",
"deleteConfirmTitle": "Confirm delete user?",
"deleteConfirmDescription": "This will permanently delete the user and their associated data. This action cannot be undone. If the user is associated with classes or students, please remove the associations first.",
"columns": {
"name": "Name",
"email": "Email",
"role": "Role",
"phone": "Phone",
"createdAt": "Registered",
"actions": "Actions"
},
"actions": {
"edit": "Edit",
"assignRoles": "Assign Roles",
"delete": "Delete",
"cancel": "Cancel",
"deleting": "Deleting...",
"confirmDelete": "Confirm Delete"
},
"pagination": {
"info": "Showing {start}-{end} of {total} items",
"prev": "Previous",
"pageInfo": "Page {page} / {totalPages}",
"next": "Next"
},
"messages": {
"deleted": "User deleted"
},
"import": {
"title": "Batch Import Users",
"description": "Batch create user accounts via Excel file.",
@@ -41,4 +78,4 @@
"fieldInviteCodeRequired": "Optional",
"fieldInviteCodeDescription": "Only valid for student role, 6-digit invite code"
}
}
}

View File

@@ -17,7 +17,22 @@
"teacher": ["帮我批改这道题", "生成一个课堂活动", "创建一道测验题"],
"student": ["解释这个概念", "给我一道练习题", "帮我复习"],
"parent": ["我孩子学得怎么样?", "在家应该关注什么?"],
"admin": ["显示 AI 使用统计", "哪些老师最常使用 AI"]
"admin": ["显示 AI 使用统计", "哪些老师最常使用 AI"],
"context": {
"teacherGrading": ["这类题目常见错误有哪些?", "如何给出建设性反馈?"],
"teacherLesson": ["为这节课建议一个导入", "有哪些分层教学策略?"],
"teacherExam": ["生成一道相关题目", "分析难度分布"],
"studentHomework": ["给我提示,不要答案", "帮我理解这个概念"]
}
},
"contextMessage": {
"teacherGrading": "当前页面:作业批改视图",
"teacherLesson": "当前页面:备课编辑器",
"teacherExam": "当前页面:试卷组卷",
"studentErrorBook": "当前页面:错题本(学生视图)",
"studentHomework": "当前页面:学生作业视图",
"parent": "当前页面:家长面板",
"admin": "当前页面:管理员面板"
}
},
"provider": {
@@ -193,6 +208,7 @@
"contentFailed": "内容生成失败",
"variantFailed": "题目变体生成失败",
"analysisFailed": "薄弱点分析失败",
"statsFailed": "AI 使用统计查询失败",
"boundaryTitle": "AI 功能错误",
"boundaryDescription": "处理 AI 请求时发生错误,请重试。",
"retry": "重试",
@@ -208,6 +224,10 @@
"similarQuestion": "AI 相似题",
"weaknessAnalysis": "AI 薄弱点分析",
"childSummary": "AI 子女摘要",
"studyPath": "AI 学习路径"
"studyPath": "AI 学习路径",
"explainError": "AI 错题解释"
},
"chart": {
"parseError": "图表数据格式错误,无法渲染"
}
}

View File

@@ -9,7 +9,9 @@
"description": {
"list": "随时了解学校最新公告动态。",
"adminList": "创建并管理全校公告。",
"edit": "更新公告详情。"
"edit": "更新公告详情。",
"detail": "查看公告详情。",
"create": "创建一条新公告。"
},
"filter": {
"all": "全部",
@@ -75,7 +77,11 @@
"invalidForm": "表单数据无效",
"invalidState": "表单状态无效",
"pinToggled": "置顶状态已更新",
"markedRead": "已标记为已读"
"markedRead": "已标记为已读",
"invalidFormData": "表单数据无效,请检查后重试。",
"unexpectedError": "发生意外错误,请稍后重试。",
"permissionDenied": "您没有执行该操作的权限。",
"forbidden": "您无权操作此公告。"
},
"meta": {
"publishedAt": "发布于 {date}",
@@ -99,5 +105,11 @@
"loadFailed": "公告加载失败",
"loadFailedDesc": "抱歉,加载公告时发生了意外错误。请稍后重试。",
"retry": "重试"
},
"pagination": {
"label": "公告分页导航",
"prev": "上一页",
"next": "下一页",
"page": "第 {page} 页"
}
}

View File

@@ -20,7 +20,17 @@
"absent": "缺勤",
"late": "迟到",
"early_leave": "早退",
"excused": "请假"
"excused": "请假",
"school_activity": "校内活动"
},
"period": {
"label": "节次",
"morning_reading": "早读",
"morning": "上午",
"afternoon": "下午",
"evening": "晚自习",
"full_day": "全日",
"allPeriods": "全部节次"
},
"stats": {
"totalRecords": "总记录数",
@@ -29,6 +39,7 @@
"late": "迟到",
"earlyLeave": "早退",
"excused": "请假",
"schoolActivity": "校内活动",
"attendanceRate": "出勤率",
"lateRate": "迟到率",
"recentRecords": "最近记录",
@@ -63,6 +74,7 @@
"class": "班级",
"date": "日期",
"status": "状态",
"reason": "原因",
"remark": "备注",
"recorder": "记录人",
"createdAt": "创建时间"
@@ -77,15 +89,122 @@
"confirmClassSwitch": "切换班级将丢弃未保存的修改,是否继续?",
"confirmClassSwitchAction": "切换班级",
"saved": "考勤已保存",
"saving": "保存中...",
"updated": "考勤已更新",
"deleted": "考勤记录已删除"
"deleted": "考勤记录已删除",
"reasonPlaceholder": "请输入原因"
},
"rules": {
"lateThreshold": "迟到阈值(分钟)",
"earlyLeaveThreshold": "早退阈值(分钟)",
"enableAutoMark": "启用自动标记",
"attendanceRateThreshold": "出勤率阈值(%",
"attendanceRateThresholdHint": "低于此值的学生将触发预警",
"consecutiveAbsenceThreshold": "连续缺勤阈值(次)",
"consecutiveAbsenceThresholdHint": "连续缺勤达到此次数的学生将触发预警",
"saved": "考勤规则已保存"
},
"warnings": {
"title": "出勤率预警",
"noWarnings": "暂无预警",
"noWarningsDescription": "当前班级所有学生出勤情况正常。",
"thresholdSummary": "出勤率阈值 {rate}% / 连续缺勤阈值 {absence} 次",
"dates": "相关日期",
"types": {
"low_attendance_rate": "出勤率 {current}% 低于阈值 {threshold}%",
"consecutive_absence": "连续缺勤 {current} 次,达到阈值 {threshold} 次"
},
"severity": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"trend": {
"title": "考勤趋势",
"description": "{start} 至 {end}",
"noData": "暂无趋势数据",
"noDataDescription": "当前班级在所选时间范围内暂无考勤记录。",
"granularity": {
"daily": "按日",
"weekly": "按周",
"monthly": "按月"
},
"series": {
"presentRate": "出勤率",
"lateRate": "迟到率",
"absentRate": "缺勤率"
}
},
"comparison": {
"title": "跨班级对比",
"description": "{grade} · {start} 至 {end} · 年级平均出勤率 {avg}%",
"noData": "暂无对比数据",
"noDataDescription": "请选择年级查看班级出勤率对比。",
"aboveAverage": "高于平均",
"average": "持平",
"belowAverage": "低于平均",
"series": {
"presentRate": "出勤率"
},
"columns": {
"class": "班级",
"total": "总记录",
"presentRate": "出勤率",
"lateRate": "迟到率",
"absentRate": "缺勤率",
"badge": "评估"
}
},
"report": {
"title": "考勤报告",
"description": "生成班级考勤周报/月报,支持打印为 PDF。",
"controls": "报告设置",
"type": "报告类型",
"types": {
"weekly": "周报",
"monthly": "月报"
},
"startDate": "开始日期",
"endDate": "结束日期",
"print": "打印 / 保存 PDF",
"printing": "准备打印...",
"period": "报告周期",
"generatedAt": "生成日期",
"summary": "统计汇总",
"studentDetails": "学生明细",
"parentSignature": "家长签字单",
"parentSignatureNotice": "请家长确认已阅子女考勤情况,签字后交回班主任。",
"parentName": "家长姓名",
"signature": "家长签字",
"relationship": "与学生关系",
"signDate": "签字日期",
"parentComment": "家长意见",
"footer": "本报告由系统自动生成,如有疑问请联系班主任。",
"noData": "暂无报告数据",
"noDataDescription": "当前班级在所选时间范围内暂无考勤记录。"
},
"correlation": {
"title": "考勤与成绩关联分析",
"description": "{className} · {start} 至 {end} · 出勤率与平均成绩的相关性",
"noData": "暂无关联分析数据",
"noDataDescription": "所选班级在时间范围内无考勤或成绩记录,无法进行关联分析。",
"noStudents": "暂无学生数据",
"noStudentsDescription": "该班级在时间范围内无考勤与成绩匹配的学生。",
"correlationCoefficient": "相关系数 r",
"notAvailable": "数据不足",
"scatterTitle": "出勤率-成绩散点图",
"scatterSeries": "学生",
"studentDetails": "学生明细",
"riskLevelLabel": "风险等级",
"attendanceRate": "出勤率",
"averageScore": "平均成绩",
"absentCount": "缺勤次数",
"gradeRecordCount": "成绩记录数",
"riskCounts": { "high": "高风险学生数", "medium": "中风险学生数", "low": "低风险学生数" },
"riskLevel": { "high": "高风险", "medium": "中风险", "low": "低风险" },
"interpretation": { "strong_negative": "强负相关:出勤率越低,成绩越低", "weak_negative": "弱负相关:出勤率与成绩有一定负相关", "negligible": "无明显线性相关", "weak_positive": "弱正相关:出勤率与成绩有一定正相关", "strong_positive": "强正相关:出勤率越高,成绩越高", "insufficient_data": "数据不足,无法计算相关性" }
},
"errors": {
"notFound": "考勤记录不存在",
"noOwnership": "您无权操作此考勤记录",
@@ -94,7 +213,10 @@
"invalidForm": "表单数据无效",
"missingRecords": "缺少考勤数据",
"invalidRecordsJson": "考勤数据格式无效",
"unexpected": "发生未知错误"
"unexpected": "发生未知错误",
"invalid_date": "日期格式无效",
"invalid_startDate": "开始日期格式无效",
"invalid_endDate": "结束日期格式无效"
},
"messages": {
"recorded": "考勤已记录",

View File

@@ -1,6 +1,86 @@
{
"title": "审计日志",
"description": "追踪系统内所有用户操作,保障安全与合规。",
"table": {
"user": "用户",
"module": "模块",
"action": "操作",
"target": "目标",
"status": "状态",
"ipAddress": "IP 地址",
"time": "时间",
"userAgent": "用户代理",
"tableName": "数据表",
"recordId": "记录 ID",
"changedBy": "操作人",
"view": "查看",
"hide": "隐藏",
"oldValue": "旧值",
"newValue": "新值"
},
"filter": {
"anyModule": "任意模块",
"anyStatus": "任意状态",
"anyAction": "任意操作",
"anyTable": "任意数据表",
"actionPlaceholder": "操作...",
"userIdPlaceholder": "用户 ID...",
"modulePlaceholder": "模块",
"statusPlaceholder": "状态",
"tablePlaceholder": "数据表",
"actionSelectPlaceholder": "操作",
"startDate": "开始日期",
"endDate": "结束日期",
"success": "成功",
"failure": "失败",
"create": "创建",
"update": "更新",
"delete": "删除",
"signIn": "登录",
"signOut": "登出",
"signUp": "注册",
"reset": "重置"
},
"empty": {
"audit": "暂无审计日志",
"login": "暂无登录日志",
"dataChange": "暂无数据变更日志"
},
"export": {
"button": "导出 Excel",
"success": "导出成功",
"failed": "导出失败"
},
"error": {
"title": "加载失败",
"description": "数据加载时发生错误,请稍后重试。",
"retry": "重试",
"boundaryTitle": "审计数据加载失败",
"boundaryDescription": "审计数据区块加载时发生错误,请重试。"
},
"detail": {
"title": "日志详情",
"description": "查看日志的完整字段信息。",
"userId": "用户 ID",
"userName": "用户名",
"action": "操作",
"module": "模块",
"targetId": "目标 ID",
"targetType": "目标类型",
"detail": "详情",
"ipAddress": "IP 地址",
"userAgent": "用户代理",
"status": "状态",
"createdAt": "时间",
"errorMessage": "错误信息",
"tableName": "数据表",
"recordId": "记录 ID",
"changedBy": "操作人",
"oldValue": "旧值",
"newValue": "新值",
"close": "关闭",
"viewDetail": "查看详情"
},
"loginLogs": {
"title": "登录日志",
"description": "监控所有认证事件,包括登录、登出与注册。"
@@ -8,5 +88,50 @@
"dataChanges": {
"title": "数据变更日志",
"description": "追踪系统所有数据变更(增删改),保障合规。"
},
"overview": {
"title": "审计概览",
"description": "实时掌握系统活动、安全事件与数据变更全貌。",
"stats": {
"auditEventsToday": "今日审计事件",
"failedLoginsToday": "今日失败登录",
"dataChangesToday": "今日数据变更",
"totalAuditLogs": "审计日志总数"
},
"trendChart": {
"title": "活动趋势(近 7 天)",
"description": "审计事件、登录事件与数据变更的每日计数。",
"auditEvents": "审计事件",
"loginEvents": "登录事件",
"dataChanges": "数据变更"
},
"distributionChart": {
"title": "数据变更按操作分布",
"description": "创建、更新、删除操作的占比统计。",
"empty": "暂无数据变更记录。"
},
"quickLinks": {
"title": "快捷入口",
"auditLogs": "查看审计日志",
"loginLogs": "查看登录日志",
"dataChanges": "查看数据变更"
}
},
"retention": {
"title": "数据保留策略",
"description": "配置审计日志的保留期限,超期日志将被自动清理。",
"retentionDays": "审计日志保留天数",
"retentionDaysDescription": "超过此天数的审计日志与数据变更日志将被删除。最小 30 天,最大 3650 天。",
"loginLogRetentionDays": "登录日志保留天数",
"loginLogRetentionDaysDescription": "登录日志(含登录成功/失败/注册/登出)的保留期限,建议长于审计日志以支持安全取证。最小 30 天,最大 3650 天。",
"autoCleanupEnabled": "启用自动清理",
"autoCleanupEnabledDescription": "启用后,将通过定时任务每日清理过期日志。",
"save": "保存配置",
"purge": "立即清理",
"purgeConfirm": "此操作将立即删除所有超过保留期限的日志。是否继续?",
"purgeSuccess": "清理完成:已删除 {auditLogsDeleted} 条审计日志、{loginLogsDeleted} 条登录日志、{dataChangeLogsDeleted} 条数据变更日志。",
"saveSuccess": "保留策略配置已保存。",
"saveFailed": "保存配置失败。",
"purgeFailed": "清理日志失败。"
}
}
}

View File

@@ -2,14 +2,31 @@
"login": {
"title": "登录",
"subtitle": "使用您的账号登录 Next_Edu",
"subtitle2fa": "请输入身份验证器中的 6 位验证码",
"email": "邮箱",
"password": "密码",
"rememberMe": "记住我",
"signIn": "登录",
"verifyAndSignIn": "验证并登录",
"signingIn": "登录中...",
"forgotPassword": "忘记密码?",
"noAccount": "还没有账号?",
"register": "注册"
"register": "注册",
"totpLabel": "2FA 验证码",
"totpHint": "请输入 6 位身份验证器验证码或 8 位备份码。",
"backToLogin": "返回登录",
"orContinueWith": "或继续使用",
"errors": {
"invalidCredentials": "邮箱或密码错误",
"invalid2fa": "2FA 验证码错误,请重试。",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码",
"emailFormat": "邮箱格式不正确",
"totpRequired": "请输入 2FA 验证码",
"tooManyAttempts": "尝试次数过多,账户可能已被锁定。请稍后再试或联系管理员。",
"accountLockedHint": "连续登录失败多次,账户可能已被临时锁定。可稍后重试或使用「忘记密码」重置。"
},
"attemptsRemaining": "剩余尝试次数:{count}"
},
"register": {
"title": "注册",
@@ -21,7 +38,51 @@
"signUp": "注册",
"signingUp": "注册中...",
"hasAccount": "已有账号?",
"signIn": "登录"
"signIn": "登录",
"createAccount": "创建账户",
"subtitleAlt": "填写以下信息以创建您的账户",
"birthDate": "出生日期",
"currentAge": "当前年龄:{age} 岁",
"guardianSectionTitle": "检测到您是未成年人,请填写监护人信息",
"guardianName": "监护人姓名",
"guardianNamePlaceholder": "请输入监护人姓名",
"guardianPhone": "监护人电话",
"guardianPhonePlaceholder": "请输入监护人手机号",
"guardianRelation": "监护人与您的关系",
"guardianRelationPlaceholder": "请选择关系",
"guardianRelationFather": "父亲",
"guardianRelationMother": "母亲",
"guardianRelationOther": "其他法定监护人",
"namePlaceholder": "请输入姓名",
"agreeTermsLabel": "我已阅读并同意《隐私政策》和《用户协议》",
"agreeTermsRich": "我已阅读并同意 <privacy>《隐私政策》</privacy> 和 <terms>《用户协议》</terms>",
"agreeTermsLinkPrivacy": "《隐私政策》",
"agreeTermsLinkTerms": "《用户协议》",
"agreeTermsAndLabel": "和",
"agreeGuardianLabel": "我确认已获得监护人同意使用本服务",
"loginNow": "立即登录",
"alreadyHaveAccount": "已有账户?",
"toast": {
"needAgreeTerms": "请阅读并同意《隐私政策》和《用户协议》后再注册",
"needGuardianConsent": "未成年人注册须确认已获得监护人同意",
"needGuardianRelation": "请选择监护人与您的关系",
"createSuccess": "账户创建成功",
"createFailed": "注册失败"
},
"errors": {
"RATE_LIMIT_EXCEEDED": "注册尝试过于频繁,请稍后再试",
"VALIDATION_FAILED": "请检查输入信息是否正确",
"EMAIL_TAKEN": "该邮箱已注册",
"BREACHED_PASSWORD": "该密码已在已知数据泄露中出现,请更换一个更安全的密码",
"INVITATION_CODE_INVALID": "邀请码无效或已使用",
"INVITATION_CODE_USED": "邀请码已被使用",
"DEFAULT_ROLE_MISSING": "系统初始化异常,请联系管理员",
"UNKNOWN": "创建账户失败,请稍后重试"
},
"emailFormatError": "邮箱格式不正确",
"invitationCodeLabel": "邀请码(可选)",
"invitationCodePlaceholder": "如有邀请码请输入",
"invitationCodeMax": "邀请码长度不能超过 64 个字符"
},
"errors": {
"invalidCredentials": "邮箱或密码错误",

View File

@@ -30,9 +30,9 @@
"alreadyInClass": "你已在该班级中",
"subjectConflict": "该班级此科目已有任课教师",
"generateSuccess": "邀请码生成成功",
"generateFailed": "邀请码生成失败",
"generateFailed": "生成邀请码失败",
"revokeSuccess": "邀请码已撤销",
"revokeFailed": "邀请码撤销失败",
"revokeFailed": "撤销邀请码失败",
"regenerateSuccess": "邀请码已重新生成",
"regenerateConfirm": "重新生成后旧邀请码将立即失效,确定继续吗?",
"revokeConfirm": "撤销后此邀请码将立即失效,确定继续吗?",
@@ -51,5 +51,418 @@
"homeroomTeacher": "班主任",
"studentCount": "学生人数",
"actions": "操作"
},
"list": {
"title": "班级管理",
"description": "管理班级并分配教师。",
"new": "新建班级",
"empty": {
"title": "暂无班级",
"description": "创建你的第一个班级以开始使用。",
"noMatch": "没有匹配结果",
"noMatchDescription": "尝试修改筛选条件或清空搜索。"
},
"column": {
"school": "学校",
"grade": "年级",
"name": "班级名称",
"homeroom": "班主任",
"room": "教室",
"subjectTeachers": "任课教师",
"studentCount": "学生人数",
"invitationCode": "邀请码",
"updated": "更新时间",
"actions": "操作"
},
"actions": {
"edit": "编辑",
"delete": "删除",
"viewDetail": "查看详情"
},
"failedCreate": "创建班级失败",
"failedUpdate": "更新班级失败",
"failedDelete": "删除班级失败",
"failedStatus": "更新状态失败",
"failedRegenerate": "重新生成邀请码失败",
"failedCopy": "复制失败"
},
"form": {
"createTitle": "新建班级",
"editTitle": "编辑班级",
"name": "班级名称",
"namePlaceholder": "如:一年级一班",
"school": "学校",
"schoolPlaceholder": "选择学校",
"grade": "年级",
"gradePlaceholder": "选择年级",
"homeroom": "班主任",
"homeroomPlaceholder": "选择班主任",
"homeroomLabel": "班号",
"room": "教室",
"roomPlaceholder": "可选",
"subjectTeachers": "任课教师",
"subjectTeacherPlaceholder": "选择教师",
"noSchools": "暂无学校",
"noGrades": "暂无年级",
"noTeachers": "暂无教师",
"selectSchool": "选择学校",
"selectGrade": "选择年级",
"selectTeacher": "选择教师",
"optional": "可选",
"cancel": "取消",
"create": "创建",
"save": "保存",
"saveChanges": "保存更改",
"saving": "保存中...",
"creating": "创建中...",
"deleting": "删除中...",
"adding": "添加中...",
"add": "添加"
},
"delete": {
"title": "删除班级",
"description": "确定要删除「{name}」吗?此操作不可撤销。",
"cancel": "取消",
"confirm": "删除",
"thisClass": "此班级"
},
"filters": {
"search": "搜索班级...",
"school": "学校",
"allSchools": "全部学校",
"grade": "年级",
"allGrades": "全部年级",
"class": "班级",
"allClasses": "全部班级",
"status": "状态",
"allStatuses": "全部状态",
"weekday": "星期",
"selectWeekday": "选择星期",
"allWeekdays": "全部",
"selectClass": "选择班级",
"selectSchool": "选择学校",
"selectGrade": "选择年级",
"clear": "清除筛选",
"reset": "重置",
"unknownClass": "未知班级",
"filterByClass": "按班级筛选",
"filterByStatus": "按状态筛选",
"addEvent": "添加事件",
"addStudent": "添加学生",
"email": "邮箱",
"emailPlaceholder": "student@example.com",
"subjectPlaceholderExample": "例如 数学",
"createScheduleEntryDescription": "创建一条课表项。",
"enrollStudentDescription": "通过邮箱将学生加入班级。",
"startTime": "开始",
"endTime": "结束"
},
"myClasses": {
"title": "我的班级",
"joinNew": "加入新班级",
"joinTitle": "加入班级",
"joinDescription": "输入管理员提供的 6 位邀请码。",
"invitationCode": "邀请码",
"invitationCodePlaceholder": "如123456",
"invitationCodeHint": "如果没有邀请码,请向管理员索取。",
"subject": "教学科目",
"subjectPlaceholder": "选择教学科目",
"noSubjects": "暂无可选科目",
"selectSubject": "选择教学科目",
"join": "加入班级",
"joining": "加入中...",
"cancel": "取消",
"empty": {
"title": "暂无班级",
"description": "加入一个班级或等待管理员分配。"
},
"entryPass": "入门卡",
"submissionTrends": "提交趋势",
"students": "学生",
"room": "教室",
"noRoom": "无教室",
"vsLastWeek": "较上周",
"viewDetail": "查看详情",
"copyCode": "复制邀请码",
"regenerateCode": "重新生成",
"codeCopied": "邀请码已复制"
},
"students": {
"title": "学生名单",
"searchPlaceholder": "搜索学生...",
"empty": {
"title": "暂无学生",
"description": "该班级还没有学生。",
"noMatch": "没有匹配的学生",
"noMatchDescription": "尝试清除筛选或调整关键词。"
},
"column": {
"name": "姓名",
"email": "邮箱",
"status": "状态",
"scores": "成绩",
"actions": "操作"
},
"status": {
"active": "在读",
"inactive": "停用"
},
"actions": {
"setActive": "设为在读",
"setInactive": "设为停用",
"remove": "移出班级"
},
"removeTitle": "移出班级",
"removeDescription": "确定要将「{name}」移出班级吗?"
},
"schedule": {
"title": "班级课表",
"empty": {
"title": "暂无课表",
"description": "该班级尚未设置课表。",
"noMatch": "该班级暂无课表",
"noMatchDescription": "尝试选择其他班级。"
},
"column": {
"weekday": "星期",
"period": "节次",
"subject": "科目",
"location": "地点",
"teacher": "教师",
"actions": "操作"
},
"form": {
"createTitle": "新增课表项",
"editTitle": "编辑课表项",
"createDescription": "新增一个课表项。",
"editDescription": "更新课表项信息。",
"weekday": "星期",
"period": "节次",
"subject": "科目",
"location": "地点",
"locationPlaceholder": "可选",
"subjectPlaceholder": "如:数学",
"startLabel": "开始时间",
"endLabel": "结束时间",
"cancel": "取消",
"create": "创建",
"save": "保存",
"deleteTitle": "确定删除?",
"deleteDescription": "此操作将永久删除该课表项。"
},
"weekday": {
"1": "周一",
"2": "周二",
"3": "周三",
"4": "周四",
"5": "周五",
"6": "周六",
"7": "周日"
}
},
"detail": {
"header": {
"students": "{count} 名学生",
"homeroom": "班主任 {name}",
"room": "教室 {room}",
"invite": "邀请",
"editDetails": "编辑详情",
"inviteStudents": "邀请学生",
"classSettings": "班级设置",
"moreActions": "更多操作"
},
"overview": {
"averageScore": "平均分",
"submissionRate": "提交率",
"papersToGrade": "待批改",
"overdueCount": "逾期数",
"overallPerformance": "整体表现",
"averageTurnInRate": "平均提交率",
"pendingReviews": "待审核",
"activeAssignmentsPastDue": "逾期活跃作业"
},
"trends": {
"submitted": "已提交",
"totalStudents": "总学生数",
"averageScore": "平均分",
"medianScore": "中位数",
"latest": "最新",
"submission": "提交",
"score": "成绩",
"allSubjects": "全部科目",
"scoreTrends": "成绩趋势",
"recentTurnInRates": "最近作业提交率",
"avgVsMedian": "均分与中位数对比",
"noDataForSubject": "该科目暂无数据"
},
"assignments": {
"due": "截止 {date}",
"noDueDate": "无截止日期",
"viewAll": "查看全部",
"createHomework": "创建作业",
"activeCount": "{count} 个进行中的作业",
"submittedCount": "{submitted}/{total} 已提交",
"avgLabel": "均分"
},
"widgets": {
"recentHomework": "最近作业",
"weeklySchedule": "本周课表",
"quickActions": "快捷操作",
"submissionTrends": "提交趋势",
"studentList": "学生名单"
},
"edit": {
"title": "编辑班级",
"name": "班级名称",
"homeroom": "班主任",
"room": "教室",
"cancel": "取消",
"save": "保存"
},
"empty": {
"noSchedule": "暂无课表",
"noScheduleDescription": "该班级尚未设置课表。",
"noAssignments": "暂无作业",
"noAssignmentsDescription": "该班级尚未布置任何作业。"
},
"students": {
"viewAll": "查看全部",
"activeCount": "{count} 名在读学生"
},
"schedule": {
"manage": "管理",
"showingWeekdays": "仅显示周一至周五课表"
},
"quickActions": {
"manageSchedule": "管理课表",
"messageClassComingSoon": "班级通知(即将上线)"
}
},
"grade": {
"title": "班级管理",
"description": "管理你所在年级的班级。",
"noManagedGrades": "暂无负责年级",
"noManagedGradesDescription": "你尚未管理任何年级。",
"insights": {
"title": "年级学情",
"description": "查看你负责年级的作业整体统计。",
"filters": "筛选",
"grade": "年级",
"apply": "应用",
"selectGrade": "选择年级",
"noGrades": "暂无负责年级",
"noGradesDescription": "你尚未被指派为任何年级的年级主任或教研组长。",
"selectToView": "请选择年级查看学情",
"selectToViewDescription": "选择一个年级以查看最新作业与历史成绩统计。",
"notFound": "年级不存在",
"notFoundDescription": "该年级可能不存在或暂无可访问数据。",
"noData": "该年级暂无作业数据",
"noDataDescription": "该年级学生尚未收到任何作业。",
"classes": "班级",
"students": "学生",
"overallAvg": "整体均分",
"latestAvg": "最新均分",
"homeworkTimeline": "作业时间线",
"classRanking": "班级排名",
"assignment": "作业",
"status": "状态",
"created": "创建时间",
"targeted": "目标人数",
"submitted": "已提交",
"graded": "已批改",
"avg": "均分",
"median": "中位数",
"class": "班级",
"latestAvgCol": "最新均分",
"prevAvg": "上次均分",
"delta": "变化",
"overallAvgCol": "整体均分",
"active": "活跃",
"inactive": "非活跃"
}
},
"bulk": {
"importStudents": {
"title": "批量导入学生",
"description": "每行一个邮箱格式name,email 或仅 email",
"placeholder": "张三,zhangsan@example.com\nlisi@example.com",
"submit": "导入",
"success": "成功导入 {imported} 个学生,{failed} 个失败",
"failed": "批量导入失败"
},
"assignTeachers": {
"title": "批量分配教师",
"description": "每行格式:班级名称,科目,教师邮箱",
"placeholder": "一班,语文,zhangsan@example.com\n二班,数学,lisi@example.com",
"submit": "分配",
"success": "成功更新 {updated} 个分配,{failed} 个失败",
"failed": "批量分配失败"
}
},
"errors": {
"boundary": {
"title": "加载失败",
"description": "数据加载时发生错误,请重试",
"retry": "重试"
}
},
"error": {
"title": "加载失败",
"description": "数据加载时发生错误,请重试",
"retry": "重试"
},
"actions": {
"classCreated": "班级创建成功",
"classUpdated": "班级更新成功",
"classDeleted": "班级删除成功",
"studentAdded": "学生添加成功",
"studentUpdated": "学生状态更新成功",
"invitationCodeReady": "邀请码已就绪",
"invitationCodeUpdated": "邀请码已更新",
"invitationCodeGenerated": "邀请码已生成",
"invitationCodeRevoked": "邀请码已撤销",
"scheduleItemCreated": "课表项创建成功",
"scheduleItemUpdated": "课表项更新成功",
"scheduleItemDeleted": "课表项删除成功",
"joinedClass": "加入班级成功",
"bulkImportResult": "成功导入 {imported} 个学生,{failed} 个失败",
"bulkAssignResult": "成功更新 {updated} 个分配,{failed} 个失败",
"missingClassId": "缺少班级 ID",
"missingScheduleId": "缺少课表 ID",
"missingCodeId": "缺少邀请码 ID",
"missingEnrollmentInfo": "缺少注册信息",
"invalidScheduleData": "课表项数据无效",
"invalidExpiresInHours": "有效期(小时)无效",
"invalidMaxUses": "最大使用次数无效",
"csvRequired": "CSV 数据为必填",
"noValidEntries": "未找到有效条目",
"classNameGradeTeacherRequired": "班级名称、年级和教师为必填",
"classNameGradeRequired": "班级名称和年级为必填",
"invitationCodeRequired": "邀请码为必填",
"subjectRequired": "科目为必填",
"tooManyAttempts": "尝试过于频繁,请稍后再试",
"scheduleItemNotFound": "未找到课表项",
"classNotFoundOrNotLinked": "班级不存在或未关联年级",
"notPermissionCreateGrade": "无权为该年级创建班级",
"notPermissionUpdateClass": "无权更新此班级",
"notPermissionMoveClass": "无权将班级移动到该年级",
"notPermissionDeleteClass": "无权删除此班级",
"notOwnClass": "你不是此班级的所有者",
"notPermissionManageClass": "无权管理此班级",
"notPermissionManageClassSchedule": "无权管理此班级课表",
"onlyAdminsAndGradeHeads": "仅管理员和年级主任可创建班级",
"classAndEmailRequired": "请选择班级并填写学生邮箱",
"invalidWeekday": "无效的星期值",
"invalidSubjectTeachers": "任课教师数据无效"
},
"metadata": {
"myClasses": "我的班级",
"classDetail": "班级详情",
"schedule": "班级课表",
"students": "学生名单",
"studentLearning": "学习中心",
"studentCourses": "我的课程",
"studentClassDetail": "班级主页",
"studentSchedule": "我的课表"
}
}

View File

@@ -67,5 +67,20 @@
},
"breadcrumb": {
"home": "首页"
},
"widget": {
"error": "组件加载失败",
"retry": "重试",
"loading": "加载中...",
"block": "区块",
"loadFailed": "{title}加载失败",
"defaultFallback": "请重试或刷新页面",
"retryAriaLabel": "重试加载{title}",
"loadingAriaLabel": "{title}加载中"
},
"error": {
"boundaryTitle": "区块加载失败",
"boundaryDescription": "处理请求时发生错误,请重试。",
"retry": "重试"
}
}

View File

@@ -12,5 +12,223 @@
"create": {
"title": "新建课程计划",
"description": "创建新的课程教学计划。"
},
"status": {
"planning": "规划中",
"active": "进行中",
"completed": "已完成",
"paused": "已暂停"
},
"filter": {
"all": "全部",
"placeholder": "按状态筛选"
},
"list": {
"new": "新建课程计划",
"empty": "暂无课程计划。",
"emptyFiltered": "当前筛选条件下无课程计划。",
"unknownSubject": "未知学科",
"noClass": "无班级",
"semester": "第 {semester} 学期",
"created": "创建于 {date}",
"teacher": "教师:{name}",
"unassigned": "未分配",
"emptyCta": "立即创建第一个课程计划"
},
"detail": {
"back": "返回课程计划列表",
"heading": "课程计划",
"edit": "编辑",
"delete": "删除",
"noClass": "无班级",
"unknownSubject": "未知学科",
"unknownSubjectHeading": "课程计划",
"semester": "第 {semester} 学期",
"teacher": "教师:{name}",
"unassigned": "未分配",
"created": "创建于 {date}",
"startDate": "开始 {date}",
"endDate": "结束 {date}",
"syllabus": "教学大纲",
"objectives": "教学目标",
"weekPlans": "周计划",
"addWeekPlan": "添加周计划",
"emptyWeekPlans": "暂无周计划。",
"emptyWeekPlansCta": "点击「添加周计划」创建第一条。",
"week": "周次",
"topic": "主题",
"hours": "课时",
"chapter": "章节",
"statusCol": "状态",
"completed": "已完成",
"pending": "待完成",
"notes": "备注:{notes}",
"dragHandle": "拖拽手柄(按住可调整顺序)",
"selectWeekAria": "选择第 {week} 周",
"reorderFailed": "调整顺序失败,请重试",
"reorderSaved": "顺序已保存",
"viewTextbookAria": "查看教材章节:{chapter}",
"viewHomeworkAria": "查看相关作业",
"deleteTitle": "删除课程计划",
"deleteDescription": "此操作将永久删除该课程计划及其所有周计划。"
},
"form": {
"new": "新建课程计划",
"edit": "编辑课程计划",
"class": "班级",
"selectClass": "选择班级",
"subject": "学科",
"selectSubject": "选择学科",
"teacher": "教师",
"selectTeacher": "选择教师",
"academicYear": "学年",
"optional": "可选",
"semester": "学期",
"semester1": "第一学期",
"semester2": "第二学期",
"status": "状态",
"selectStatus": "选择状态",
"totalHours": "总课时",
"weeklyHours": "周课时",
"startDate": "开始日期",
"endDate": "结束日期",
"syllabus": "教学大纲",
"syllabusPlaceholder": "教学大纲内容...",
"objectives": "教学目标",
"objectivesPlaceholder": "教学目标内容...",
"cancel": "取消",
"create": "创建",
"save": "保存",
"saving": "保存中...",
"invalidState": "表单状态无效",
"saveFailed": "保存课程计划失败"
},
"item": {
"addTitle": "添加周计划",
"editTitle": "编辑周计划",
"week": "周次",
"hours": "课时",
"topic": "主题",
"topicPlaceholder": "本周主题",
"content": "内容",
"contentPlaceholder": "教学内容...",
"chapter": "教材章节",
"chapterPlaceholder": "如:第三章",
"completedAt": "完成日期",
"notes": "备注",
"notesPlaceholder": "备注...",
"markComplete": "标记完成",
"markIncomplete": "标记未完成",
"delete": "删除",
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"invalidState": "表单状态无效",
"saveFailed": "保存周计划失败",
"deleteFailed": "删除失败",
"updateFailed": "更新失败"
},
"progress": {
"label": "进度",
"hours": "{completed} / {total} 课时({percent}%",
"weekPlansCompleted": "{completed} / {total} 个周计划已完成"
},
"toast": {
"created": "课程计划已创建",
"updated": "课程计划已更新",
"deleted": "课程计划已删除",
"weekAdded": "周计划已添加",
"weekUpdated": "周计划已更新",
"weekDeleted": "周计划已删除",
"markedComplete": "已标记为完成",
"markedIncomplete": "已标记为未完成",
"deleteFailed": "删除失败",
"saveFailed": "保存失败",
"reorderSaved": "排序已保存",
"reorderFailed": "排序保存失败",
"bulkMarked": "已批量标记 {count} 条完成",
"bulkFailed": "批量操作失败",
"copySuccess": "课程计划已复制到 {count} 个班级",
"copyFailed": "复制失败",
"exportFailed": "导出失败",
"invalidGradeId": "年级 ID 无效"
},
"teacher": {
"title": "我的课程计划",
"description": "查看您的课程教学计划与周课时安排。"
},
"parent": {
"title": "孩子课程计划",
"description": "查看孩子所在班级的课程教学计划。"
},
"student": {
"title": "课程计划",
"description": "查看本班课程教学计划与每周教学安排。"
},
"errors": {
"notFound": "课程计划不存在",
"invalidForm": "表单数据无效",
"invalidGradeId": "年级 ID 无效",
"noPermission": "无权访问该课程计划",
"loadFailed": "课程计划加载失败",
"loadFailedDesc": "加载课程计划数据时发生错误,请稍后重试。",
"retry": "重试"
},
"error": {
"boundaryTitle": "课程计划区块加载失败",
"boundaryDescription": "加载课程计划数据时发生错误,请重试。",
"retry": "重试"
},
"calendar": {
"title": "日历视图",
"list": "列表视图",
"noPlans": "该时间段暂无教学安排",
"prevMonth": "上一月",
"nextMonth": "下一月",
"today": "今天",
"monthTitle": "{year}年{month}月",
"weekShort": ["一", "二", "三", "四", "五", "六", "日"],
"weekFull": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
"week": "第 {week} 周",
"hours": "{hours} 课时",
"completed": "已完成",
"pending": "待完成",
"noStartDate": "课程计划未设置开始日期,无法在日历中显示教学安排。"
},
"export": {
"title": "导出",
"csv": "导出 CSV",
"pdf": "导出 PDF",
"excel": "导出 Excel",
"exported": "课程计划已导出",
"exportFailed": "导出失败",
"filename": "课程计划-{subject}-{className}",
"content": "内容",
"notes": "备注"
},
"bulk": {
"markComplete": "批量标记完成",
"copy": "复制到其他班级",
"copyTitle": "选择目标班级",
"confirm": "确认复制",
"selectTargets": "选择要复制到的班级"
},
"templates": {
"title": "课程计划模板",
"empty": "暂无模板",
"createFromTemplate": "从模板创建",
"saveAsTemplate": "存为模板",
"searchPlaceholder": "搜索班级 / 学科 / 教师",
"confirm": "确认创建",
"cancel": "取消",
"cloning": "正在创建...",
"cloneSuccess": "已从模板创建课程计划",
"cloneFailed": "从模板创建失败",
"selectTemplateFirst": "请先选择一个模板",
"noTargetClass": "请先选择目标班级"
},
"loading": {
"title": "加载中...",
"description": "正在加载课程计划数据..."
}
}
}

View File

@@ -90,6 +90,8 @@
"empty": "今日无待办事项"
},
"sections": {
"quickStats": "快速统计",
"trends": "趋势图表",
"userGrowthTrend": "用户增长趋势近30天",
"homeworkSubmissionTrend": "作业提交趋势近7天",
"userRoles": "用户角色分布",
@@ -106,7 +108,9 @@
"homework": "作业",
"recentSubmissions": "最近提交",
"classPerformance": "班级表现",
"recentGrades": "最近成绩"
"recentGrades": "最近成绩",
"notifications": "通知中心",
"viewAllNotifications": "查看全部通知"
},
"table": {
"name": "姓名",
@@ -148,7 +152,9 @@
"noData": "暂无数据",
"noDataDesc": "发布作业后即可查看班级表现趋势。",
"noGradedWork": "暂无已批改作业",
"noGradedWorkDesc": "完成并提交作业后即可查看成绩趋势。"
"noGradedWorkDesc": "完成并提交作业后即可查看成绩趋势。",
"noNotifications": "暂无通知",
"noNotificationsDesc": "您没有新的通知消息。"
},
"badge": {
"activeSessions": "{count} 个活跃会话",
@@ -177,6 +183,26 @@
"latest": "最新",
"points": "得分"
},
"timeRange": {
"label": "时间范围",
"today": "今日",
"week": "本周",
"month": "本月"
},
"comparison": {
"label": "数据对比",
"vsLastPeriod": "对比上一周期",
"increase": "增长 {value}%",
"decrease": "下降 {value}%",
"noChange": "持平",
"noData": "暂无对比数据"
},
"export": {
"label": "导出",
"csv": "导出 CSV",
"success": "导出成功",
"failed": "导出失败"
},
"schedule": {
"noDueDate": "无截止日期",
"scrollForMore": "滚动查看更多",

View File

@@ -75,7 +75,7 @@
"empty": "暂无强项知识点"
},
"weaknesses": {
"title": "弱项(<60%",
"title": "弱项(<80%",
"practice": "练习",
"empty": "暂无弱项知识点"
},
@@ -92,13 +92,25 @@
"deleteFailed": "删除失败",
"loadFailed": "加载失败",
"exportFailed": "导出失败",
"retry": "重试"
"retry": "重试",
"pageTitle": "学情诊断页面加载失败",
"pageDescription": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"classLoadFailed": "班级学情诊断加载失败",
"classLoadFailedDesc": "抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。",
"studentLoadFailed": "学生学情诊断加载失败",
"studentLoadFailedDesc": "抱歉,加载学生诊断数据时发生了意外错误。请稍后重试。",
"parentLoadFailed": "子女学情诊断加载失败",
"parentLoadFailedDesc": "抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。",
"childLoadFailed": "{studentName} 的诊断数据加载失败",
"childLoadFailedDesc": "请刷新页面,若问题持续存在请联系学校管理员。",
"reportNotFound": "报告不存在"
},
"classDiagnostic": {
"noClassDataTitle": "暂无班级数据",
"heatmapDescription": "各知识点平均掌握度绿色≥80%优秀黄色60-79%良好橙色40-59%待提升,红色<40%薄弱)。",
"noKnowledgePointData": "暂无知识点数据。",
"heatmapAriaLabel": "知识点掌握度热力图,共 {count} 个知识点颜色表示掌握度等级绿色≥80%优秀黄色60-79%良好橙色40-59%待提升,红色<40%薄弱",
"heatmapCellAriaLabel": "{name}{level}%{label}{mastered}/{total}",
"masteryLevelExcellent": "优秀",
"masteryLevelGood": "良好",
"masteryLevelNeedsImprovement": "待提升",
@@ -150,15 +162,6 @@
"deleteAriaLabel": "删除报告 {studentName}",
"exportAriaLabel": "导出报告 {studentName}",
"exportSuccess": "报告已导出",
"share": "分享",
"shareAriaLabel": "分享报告 {studentName}",
"shareTitle": "分享报告",
"shareDescription": "复制报告链接分享给学生或家长。",
"shareLinkLabel": "报告链接",
"copyLink": "复制链接",
"copyLinkSuccess": "链接已复制",
"copyLinkFailed": "复制链接失败",
"shareLinkAriaLabel": "报告分享链接",
"confidenceColumn": "置信度",
"confidenceHigh": "高",
"confidenceMedium": "中",
@@ -199,6 +202,52 @@
"weaknesses": "弱项",
"reports": "诊断报告",
"noReports": "该子女暂无诊断报告。",
"noReportsTitle": "暂无诊断报告",
"noReportsDescription": "该子女暂无诊断报告,生成后将显示在此处。",
"mastery": "掌握度"
},
"reportContent": {
"studentSummary": "学生 {studentName} 在 {period} 期间整体掌握度 {score}%,共评估 {total} 个知识点,强项 {strengths} 个,弱项 {weaknesses} 个。",
"studentRecommendation": "建议复习「{kpName}」知识点,多做相关练习以提升掌握度(当前 {level}%)。",
"studentNoWeakness": "整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。",
"classSummary": "班级 {className} 在 {period} 期间整体掌握度 {score}%,学生 {students} 人,需重点关注 {attention} 人。",
"classRecommendation": "班级在「{kpName}」整体掌握度偏低({level}%),建议安排专项复习与巩固练习。",
"classNoWeakness": "班级整体掌握情况良好,建议保持当前教学节奏。",
"gradeSummary": "年级 {gradeName} 在 {period} 期间整体掌握度 {score}%,学生 {students} 人,需重点关注 {attention} 人。",
"gradeRecommendation": "年级在「{kpName}」整体掌握度偏低({level}%),建议协调年级教师安排专项复习。",
"gradeNoWeakness": "年级整体掌握情况良好,建议保持当前教学节奏。"
},
"exportContent": {
"sheetOverview": "报告概览",
"sheetMastery": "知识点掌握度",
"sheetClassStats": "知识点统计",
"sheetAttentionStudents": "需关注学生",
"metricStudent": "学生姓名",
"metricClass": "班级",
"metricPeriod": "报告周期",
"metricScore": "综合得分",
"metricStatus": "报告状态",
"metricGeneratedBy": "生成人",
"metricCreatedAt": "生成时间",
"metricSummary": "摘要",
"metricStrengths": "强项",
"metricWeaknesses": "弱项",
"metricRecommendations": "建议",
"metricReportType": "报告类型",
"metricStudentCount": "学生人数",
"metricAttentionCount": "需关注人数",
"colKnowledgePoint": "知识点",
"colMasteryLevel": "掌握度",
"colTotalQuestions": "总题数",
"colCorrectQuestions": "正确数",
"colLastAssessed": "最近评估",
"colMasteredCount": "已掌握(≥80%)",
"colNotMasteredCount": "未掌握(<60%)",
"colTotalStudents": "总人数",
"colStudentName": "学生姓名",
"colAverageMastery": "平均掌握度",
"colWeakCount": "弱项数",
"noAttentionStudents": "所有学生均高于关注阈值",
"filename": "诊断报告_{period}_{date}.xlsx"
}
}

View File

@@ -3,6 +3,7 @@
"adminList": "选修课程",
"create": "创建课程",
"edit": "编辑课程",
"detail": "课程详情",
"teacher": "我的选修课",
"student": "选课中心"
},
@@ -10,6 +11,7 @@
"adminList": "管理选修课程、开放/关闭选课与抽签。",
"create": "创建新的选修课程。",
"edit": "更新选修课程详情。",
"detail": "查看选修课程详细信息与学生选课情况。",
"teacher": "查看和管理您教授的选修课程。",
"student": "浏览可选课程并进行选课。"
},
@@ -48,7 +50,9 @@
"selectionStart": "选课开始",
"selectionEnd": "选课结束",
"selectionMode": "选课模式",
"credit": "学分"
"credit": "学分",
"dropDeadline": "退课截止时间",
"dropReason": "退课理由"
},
"actions": {
"create": "创建课程",
@@ -72,7 +76,49 @@
"createTitle": "创建选修课程",
"editTitle": "编辑选修课程",
"namePlaceholder": "请输入课程名称",
"descriptionPlaceholder": "请输入课程描述"
"descriptionPlaceholder": "请输入课程描述",
"nameLabel": "课程名称 *",
"subjectLabel": "科目",
"gradeLabel": "年级",
"teacherLabel": "授课教师",
"capacityLabel": "容量",
"classroomLabel": "教室",
"scheduleLabel": "上课时间",
"schedulePlaceholder": "例如:周一 14:00-15:30",
"creditLabel": "学分",
"startDateLabel": "开始日期",
"endDateLabel": "结束日期",
"selectionStartLabel": "选课开始",
"selectionEndLabel": "选课结束",
"dropDeadlineLabel": "退课截止时间",
"dropDeadlineHint": "超过此时间学生不可退课,留空表示不限制。",
"descriptionLabel": "课程描述",
"cancelButton": "取消",
"saveButton": "保存",
"createButton": "创建",
"savingButton": "保存中...",
"selectSubjectPlaceholder": "请选择科目",
"selectGradePlaceholder": "请选择年级",
"selectTeacherPlaceholder": "请选择教师",
"selectModePlaceholder": "请选择选课模式",
"invalidFormState": "表单状态无效",
"saveFailed": "保存课程失败"
},
"stats": {
"totalCourses": "课程总数",
"totalEnrolled": "总选课人数",
"avgUtilization": "平均容量使用率",
"pendingLottery": "待抽签课程",
"utilization": "{enrolled}/{capacity}",
"utilizationRate": "{rate}%"
},
"parent": {
"title": "子女选课",
"description": "查看子女的选课情况。",
"noChildrenTitle": "暂无关联子女",
"noChildrenDescription": "您尚未关联任何子女,无法查看选课信息。",
"noRecordsTitle": "暂无选课记录",
"noRecordsDescription": "子女暂未选课。"
},
"student": {
"mySelections": "我的选课",
@@ -83,7 +129,19 @@
"capacityFull": "名额已满",
"selectSuccess": "选课成功",
"dropSuccess": "退课成功",
"confirmDrop": "确定退选此课程吗?"
"confirmDrop": "确定退选此课程吗?",
"dropReasonPlaceholder": "选填:请输入退课理由(最多 255 字符)"
},
"detail": {
"studentsTitle": "选课名单",
"noStudents": "暂无学生选课",
"noStudentsDescription": "尚未有学生选此课程。",
"back": "返回列表",
"editCourse": "编辑课程",
"studentName": "学生姓名",
"priority": "优先级",
"selectedAt": "选课时间",
"enrolledAt": "录取时间"
},
"lottery": {
"result": "抽签结果:录取 {enrolled} 人,候补 {waitlist} 人",
@@ -95,11 +153,28 @@
"capacityFull": "课程名额已满",
"alreadySelected": "您已选过此课程",
"selectionClosed": "选课已关闭",
"selectionNotOpen": "选课尚未开放",
"selectionNotStarted": "选课尚未开始",
"selectionEnded": "选课已结束",
"courseNotFound": "课程不存在",
"noActiveSelection": "未找到有效的选课记录",
"gradeMismatch": "您的年级不符合课程要求",
"scheduleConflict": "时间与已选课程冲突",
"creditExceeded": "学分超限({current}/{max}",
"invalidForm": "表单数据无效",
"unexpected": "发生未知错误"
"unexpected": "发生未知错误",
"dropDeadlinePassed": "退课截止时间已过,无法退课",
"title": "加载失败",
"description": "页面数据加载异常,请稍后重试。"
},
"export": {
"courseSheetName": "课程明细",
"selectionSheetName": "选课名单",
"statusHeader": "状态",
"priorityHeader": "优先级",
"selectedAtHeader": "选课时间",
"enrolledAtHeader": "录取时间",
"indexHeader": "#"
},
"messages": {
"created": "选修课程已创建",
@@ -110,5 +185,16 @@
"lotteryCompleted": "抽签完成:录取 {enrolled} 人,候补 {waitlist} 人",
"courseDropped": "已退选课程",
"ownershipCheckFailed": "归属校验失败"
},
"settings": {
"creditLimitDefault": "默认学分上限",
"creditLimitDefaultDescription": "未配置年级专属上限时使用的全局学期学分上限。",
"capacityNotifyThreshold": "容量阈值通知比例",
"capacityNotifyThresholdDescription": "课程录取人数达到容量该比例时通知教师/管理员0-1默认 0.9)。",
"saved": "设置已保存"
},
"notifications": {
"capacityWarningTitle": "课程容量预警:{courseName}",
"capacityWarningContent": "课程 \"{courseName}\" 已选满 {enrolled}/{capacity} 人(达到 {percent}% 阈值),请考虑扩容或关闭选课。"
}
}

View File

@@ -7,7 +7,12 @@
"learning": "学习中",
"mastered": "已掌握",
"dueReview": "待复习",
"masteredRate": "掌握率"
"masteredRate": "掌握率",
"totalDesc": "累计收录的错题",
"newDesc": "尚未开始复习",
"learningDesc": "正在复习掌握",
"masteredDesc": "掌握率 {rate}%",
"dueReviewDesc": "今日到期复习"
},
"status": {
"new": "待学习",
@@ -36,7 +41,9 @@
"saveNote": "保存笔记",
"archive": "归档",
"delete": "删除",
"collect": "采集错题"
"collect": "采集错题",
"cancel": "取消",
"adding": "添加中..."
},
"fields": {
"question": "选择题目",
@@ -45,7 +52,24 @@
"masteryLevel": "掌握度",
"reviewCount": "复习次数",
"nextReview": "下次复习",
"createdAt": "添加时间"
"createdAt": "添加时间",
"student": "学生",
"className": "班级"
},
"masteryLevel": {
"0": "未学习",
"1": "入门",
"2": "了解",
"3": "熟悉",
"4": "熟练",
"5": "掌握"
},
"questionType": {
"single_choice": "单选",
"multiple_choice": "多选",
"judgment": "判断",
"text": "简答",
"composite": "复合"
},
"errorTags": {
"concept": "概念不清",
@@ -56,13 +80,59 @@
"memory": "记忆错误",
"time": "时间不足"
},
"itemCard": {
"questionDeleted": "题目已删除",
"questionContent": "题目内容",
"difficulty": "难度 {level}",
"mastery": "掌握度: {level}",
"reviewTimes": "复习 {count} 次",
"needReview": "需复习",
"nextReview": "下次 {date}",
"addedAt": "添加于 {date}",
"masteryOutOf": "掌握度: {level}/5"
},
"detailDialog": {
"question": "题目",
"myAnswer": "我的答案",
"correctAnswer": "正确答案",
"aiAnalysis": "AI 智能分析",
"reviewSelf": "复习自评",
"studyNote": "学习笔记",
"notePlaceholder": "记录你的反思、解题思路、易错点...",
"errorTagsLabel": "错误原因标签",
"reviewHistory": "复习历史",
"questionDeleted": "题目已删除",
"variantPractice": "错题变式练习",
"variantPracticeDesc": "从当前错题出发,进行针对性练习"
},
"filters": {
"searchPlaceholder": "搜索笔记内容...",
"status": "状态",
"source": "来源",
"review": "复习",
"allStatus": "全部状态",
"allSource": "全部来源",
"allErrors": "全部错题",
"dueOnly": "仅看待复习"
},
"addDialog": {
"title": "添加错题",
"description": "从题库中选择题目,添加到你的错题本。你也可以在完成作业/考试后自动采集。",
"selectQuestion": "选择题目",
"selectPlaceholder": "从题库中选择...",
"noteLabel": "学习笔记(可选)",
"notePlaceholder": "记录错误原因、解题思路...",
"errorTagsLabel": "错误原因标签",
"questionPreview": "题目"
},
"empty": {
"title": "错题本为空",
"description": "完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
},
"teacher": {
"title": "错题分析",
"description": "查看班级学生的错题统计与薄弱知识点,辅助精准教学",
"description": "按学科、班级查看学生的错题统计与薄弱知识点,辅助精准教学",
"descriptionShort": "按学科、班级查看学生的错题统计与薄弱知识点。",
"coverage": "覆盖学生",
"totalErrors": "错题总数",
"avgMastery": "平均掌握率",
@@ -71,7 +141,16 @@
"studentDetail": "学生错题详情",
"topWrong": "高频错题",
"noClass": "暂无可查看的班级",
"noStudent": "班级暂无学生"
"noClassDesc": "您还未被分配到任何班级,无法查看错题分析数据。",
"noStudent": "班级暂无学生",
"noStudentDesc": "班级中没有学生,无法查看错题分析数据。",
"noChapterDataTitle": "暂无章节错题数据",
"noChapterDataDesc": "尚未关联知识点到章节,无法显示章节维度统计。",
"noKpDataTitle": "暂无知识点数据",
"noKpDataDesc": "错题尚未关联知识点,无法显示薄弱知识点统计。",
"noStudentErrorsTitle": "暂无学生错题",
"noStudentErrorsDesc": "所选范围内没有学生错题数据。",
"studentsCount": "共 {total} 名学生,{withErrors} 名有错题"
},
"parent": {
"title": "子女错题本",
@@ -104,6 +183,100 @@
"noStudentErrorsTitle": "暂无学生错题",
"noStudentErrorsDescription": "所选学科下没有学生错题数据。"
},
"analyticsStats": {
"coverage": "覆盖学生",
"coverageSub": "/ {total} 人",
"totalErrors": "错题总数",
"totalErrorsSub": "人均 {avg} 题",
"avgMastery": "平均掌握率",
"avgMasteryGood": "整体良好",
"avgMasteryNeedImprove": "需加强",
"dueReview": "待复习",
"dueReviewNeedAttention": "需要关注",
"dueReviewNone": "无到期",
"knowledgePoints": "涉及知识点",
"knowledgePointsWide": "范围较广",
"knowledgePointsFocused": "集中"
},
"subjectTabs": {
"all": "全部学科",
"dueReview": "待复习 {count}"
},
"classFilter": {
"all": "全部班级",
"errorCount": "{count} 错题",
"dueReview": "{count} 待复习"
},
"topWrong": {
"title": "高频错题",
"topTitle": "高频错题 Top 10",
"emptyTitle": "暂无高频错题",
"emptyDesc": "学生完成作业或考试后,错频统计会显示在这里。",
"errorCount": "{count} 人错",
"masteredCount": "{count} 人已掌握",
"masteryRate": "掌握率 {rate}%"
},
"weaknessChart": {
"title": "薄弱知识点 Top {count}",
"errorCount": "错题数",
"chapterLabel": "所属章节:{title}",
"masteredLabel": "已掌握",
"masteryRateLabel": "掌握率",
"unclassified": "未分类"
},
"chapterChart": {
"title": "章节错题分布(哪些课在错)",
"errorCount": "错题数",
"masteredLabel": "已掌握",
"masteryRateLabel": "掌握率",
"knowledgePointCount": "知识点数",
"weakKpsLabel": "薄弱知识点:",
"knowledgePointBadge": "{count} 个知识点"
},
"classErrorBar": {
"title": "各班级错题数对比",
"errorCount": "错题总数",
"studentCount": "学生数",
"avgPerStudent": "人均错题",
"avgMastery": "平均掌握率",
"dueReview": "待复习"
},
"subjectDistChart": {
"title": "各学科错题分布",
"errorCount": "错题数",
"masteredLabel": "已掌握",
"masteryRateLabel": "掌握率"
},
"groupedTable": {
"unclassified": "未分班",
"studentCount": "{count} 人",
"studentsWithErrors": "{count} 人有错题",
"totalErrors": "错题总数",
"avgMastery": "平均掌握率",
"student": "学生",
"new": "待学习",
"learning": "学习中",
"mastered": "已掌握",
"dueReview": "待复习",
"masteryRate": "掌握率",
"unknown": "未知"
},
"classOverview": {
"coverage": "覆盖学生",
"coverageDesc": "有错题记录的学生数",
"totalErrors": "错题总数",
"totalErrorsDesc": "班级累计错题",
"avgMastery": "平均掌握率",
"avgMasteryDesc": "已掌握错题占比",
"weakPoints": "薄弱知识点",
"weakPointsDesc": "需重点讲解",
"weakPointsTitle": "薄弱知识点 Top 10",
"subjectDist": "学科错题分布",
"noData": "暂无数据",
"errorsAndMastery": "{count} 错 · {rate}% 掌握",
"noStudentData": "暂无学生错题数据",
"noStudentDataDesc": "学生完成作业或考试后,错题数据会自动汇总到这里。"
},
"messages": {
"added": "错题已添加",
"noteSaved": "笔记已保存",
@@ -118,6 +291,8 @@
"archiveFailed": "归档失败",
"collectFailed": "采集错题失败",
"notFound": "错题不存在或无权访问",
"selectQuestion": "请选择题目"
"selectQuestion": "请选择题目",
"recordFailed": "记录失败",
"addedShort": "已添加"
}
}

View File

@@ -120,7 +120,10 @@
},
"error": {
"notFound": "考试不存在",
"loadFailed": "加载考试失败"
"loadFailed": "加载考试失败",
"boundaryTitle": "考试数据加载失败",
"boundaryDescription": "该区块数据加载时发生错误,请重试。",
"retry": "重试"
},
"analytics": {
"title": "考试分析",
@@ -149,6 +152,59 @@
"noData": "暂无分析数据,需等待学生提交并批改后生成",
"viewAnalytics": "查看分析"
},
"build": {
"title": "组卷",
"description": "为考试组装题目。",
"examStructure": "试卷结构",
"questionBank": "题库",
"preview": "预览",
"totalScore": "总分",
"saveDraft": "保存草稿",
"publish": "发布",
"richEditor": "富文本编辑器",
"richEditorHint": "切换到富文本编辑器",
"loaded": "已加载",
"startHint": "从右侧面板添加题目开始",
"itemsInStructure": "结构中有 {{count}} 项",
"saveSuccess": "考试草稿已保存",
"saveFailed": "保存失败",
"publishSuccess": "考试已发布",
"publishFailed": "发布失败",
"loadQuestionsFailed": "加载题目失败",
"newGroup": "新分组",
"newSection": "新分卷",
"score": "分值",
"sectionTitlePlaceholder": "分卷标题(如:第Ⅰ卷)",
"groupTitlePlaceholder": "分组标题",
"pts": "分",
"dragItemsHere": "拖拽题目到此处",
"addGroup": "+ 添加分组",
"addSection": "+ 添加分卷",
"section": "分卷",
"group": "分组",
"question": "题目",
"questionContent": "题目内容",
"noQuestionsFound": "未找到匹配筛选条件的题目。",
"noContentPreview": "无内容预览",
"loading": "加载中...",
"loadMore": "加载更多",
"aiVariant": "AI 变体",
"noQuestionsSelected": "尚未选择题目。从题库添加题目或创建分组。",
"createGroup": "创建分组",
"createSection": "创建分卷",
"dragGroupsHere": "拖拽分组或题目到此处",
"dragQuestionsHere": "拖拽题目到此处或从题库添加",
"untitledGroup": "未命名分组",
"previewSubject": "科目",
"previewGrade": "年级",
"previewTime": "时间",
"previewTotal": "总分",
"previewClass": "班级",
"previewName": "姓名",
"previewNo": "学号",
"emptyExamPaper": "空试卷",
"minutes": "分钟"
},
"richEditor": {
"title": "富文本组卷",
"description": "粘贴试卷文本,使用工具栏标记题目/分组/填空/加点字,右侧实时预览",
@@ -201,6 +257,167 @@
"essay": "作文题",
"text": "文本题"
}
},
"preview": {
"selectQuestionHint": "请选择左侧题目后进行编辑",
"editorTitle": "题目编辑",
"editorDescription": "直接修改或通过 AI 指令重写当前题目",
"stem": "题干",
"aiRewriteInstruction": "AI 重写指令",
"aiRewritePlaceholder": "例如:把这题改成更难、增加一个干扰项、保持总分不变。",
"aiRewriteButton": "AI 重写当前题目",
"rawModelOutput": "模型原始输出"
},
"subQuestions": {
"title": "子题",
"add": "新增子题",
"contentPlaceholder": "子题内容",
"answerPlaceholder": "参考答案",
"scorePlaceholder": "分值",
"empty": "当前题目没有子题"
},
"richForm": {
"sectionLabel": "分卷",
"partLabel": "部分",
"groupLabel": "大题",
"questionCountSummary": "(共 {{count}} 题,{{score}} 分)",
"typeSingleChoice": "单选",
"typeMultipleChoice": "多选",
"typeJudgment": "判断",
"typeComposite": "复合",
"typeShortAnswer": "简答",
"scoreUnit": "分",
"emptyPreview": "暂无题目,请在左侧编辑器中添加",
"missingExamId": "缺少试卷 ID",
"backToBuild": "返回组卷"
},
"editor": {
"loading": "编辑器加载中...",
"contentPlaceholder": "在此粘贴或输入试卷内容,选中文本可标记为题目/分组/加点字/填空..."
},
"selectionToolbar": {
"ariaLabel": "选区标记工具栏",
"sectionLabel": "分卷",
"groupLabel": "大题",
"singleChoice": "单选",
"blankShortAnswer": "填空/简答",
"composite": "复合",
"image": "图片",
"defaultGroupTitle": "一、选择题",
"defaultSectionTitle": "第Ⅰ卷 选择题"
},
"previewHook": {
"taskInterrupted": "页面刷新后任务已中断,请重新生成",
"untitledExam": "未命名试卷",
"queuedSuccess": "已加入后台队列,可继续编辑页面",
"backgroundComplete": "后台生成完成:{{title}}",
"backgroundFailed": "后台生成失败:{{title}}",
"selectQuestionFirst": "请先选择一个题目",
"questionNotFound": "未找到选中的题目",
"enterRewriteInstruction": "请输入重写指令",
"aiRewriteFailed": "AI 重写失败",
"aiRewriteSuccess": "题目已按指令重写",
"generatePreviewFailed": "生成预览失败",
"pasteSourceFirst": "请先粘贴试卷文本"
},
"paperPreview": {
"scoreWithUnit": "({{score}}分)"
},
"actionMessages": {
"enterRewriteInstruction": "请输入重写指令",
"noSelectedQuestion": "未选择题目数据",
"questionFormatInvalid": "选中题目格式无效",
"questionsDataInvalid": "题目数据格式无效",
"structureDataInvalid": "试卷结构数据格式无效",
"dbCreateFailed": "数据库错误:创建考试失败",
"createdSuccess": "考试创建成功",
"analyzeFirst": "请先分析预览后再创建",
"pasteSourceFirst": "请先粘贴完整试卷文本",
"aiQuestionFormatInvalid": "AI 题目格式无效",
"invalidUpdateData": "更新数据无效",
"onlyOwnUpdate": "只能更新自己创建的考试",
"dbUpdateFailed": "数据库错误:更新考试失败",
"updated": "考试已更新",
"invalidDeleteData": "删除数据无效",
"onlyOwnDelete": "只能删除自己创建的考试",
"dbDeleteFailed": "数据库错误:删除考试失败",
"deleted": "考试已删除",
"invalidDuplicateData": "复制数据无效",
"notFound": "考试不存在",
"dbDuplicateFailed": "数据库错误:复制考试失败",
"duplicated": "考试已复制",
"loadPreviewFailed": "加载考试预览失败",
"loadSubjectsFailed": "加载科目失败",
"loadGradesFailed": "加载年级失败",
"invalidGradeId": "年级 ID 无效",
"titleRequired": "请填写试卷标题",
"contentRequired": "试卷内容不能为空",
"contentInvalid": "试卷内容格式无效",
"contentParseFailed": "试卷内容解析失败",
"draftCreated": "试卷草稿已创建",
"examUpdated": "试卷已更新",
"sourceTextRequired": "试卷文本不能为空",
"autoMarkCompleted": "AI 自动标记完成",
"noQuestionText": "(无题目文本)"
},
"card": {
"level": "难度 {{level}}",
"minutes": "{{count}} 分钟",
"points": "{{count}} 分",
"questions": "{{count}} 题"
},
"viewer": {
"section": "分卷",
"group": "大题",
"score": "分值",
"scoreLabel": "分值:{{score}}",
"noQuestions": "暂无题目",
"unknown": "未知"
},
"previewDialog": {
"section": "分卷",
"title": "试卷预览",
"generating": "生成预览中...",
"fullPreview": "完整试卷预览",
"summary": "{{count}} 题 · {{subject}} · {{grade}} · {{minutes}} 分钟 · {{total}} 分",
"noPreview": "暂无预览内容",
"confirmCreate": "确认并创建",
"untitledQuestion": "未命名题目",
"untitledSubQuestion": "未命名子题",
"scoreUnit": "分"
},
"questionOptions": {
"label": "选项",
"addOption": "新增选项",
"correct": "正确",
"markCorrectAria": "标记选项 {{id}} 为正确答案",
"deleteOptionAria": "删除选项"
},
"editorExtensions": {
"blank": {
"ariaLabel": "填空"
},
"group": {
"titlePlaceholder": "大题标题(如:一、选择题)",
"instructionPlaceholder": "说明(如:每小题3分,共24分)—— 可留空,总分自动统计",
"statsSummary": "共 {{count}} 题 · {{score}} 分"
},
"question": {
"typeSingleChoice": "单选",
"typeMultipleChoice": "多选",
"typeJudgment": "判断",
"typeText": "填空/简答",
"typeComposite": "复合",
"scoreUnit": "分"
},
"section": {
"titlePlaceholder": "分卷标题(如:第Ⅰ卷 选择题)",
"levelTitle": "层级",
"levelVolume": "卷",
"levelPart": "部分",
"levelSubVolume": "分卷",
"statsSummary": "共 {{count}} 题 · {{score}} 分"
}
}
},
"homework": {
@@ -255,7 +472,19 @@
"titleRequired": "请输入标题",
"selectClassRequired": "请选择班级",
"createSuccess": "作业已创建",
"createFailed": "创建失败"
"createFailed": "创建失败",
"createDescription": "快速发布文本作业或从考试派生。",
"noClassesAvailable": "暂无可用班级",
"noClassesDescription": "请先创建班级,然后向该班级发布作业。",
"goToClasses": "前往班级",
"error": {
"invalidDate": "日期格式无效",
"titleRequired": "请输入标题",
"dueAfterAvailable": "截止时间必须晚于可用时间",
"lateDueAfterDue": "迟交截止时间必须晚于正常截止时间",
"lateDueRequired": "允许迟交时必须设置迟交截止时间",
"invalidForm": "表单数据无效"
}
},
"take": {
"questions": "题目",
@@ -454,10 +683,101 @@
"notAvailableYet": "作业尚未开放",
"noAttemptsLeft": "尝试次数已用完",
"assignmentNotFound": "作业不存在",
"assignmentNotAvailable": "作业不可用"
"assignmentNotAvailable": "作业不可用",
"loadFailed": "加载失败,请稍后重试",
"scanNotFound": "扫描图不存在或无权访问"
},
"filters": {
"searchPlaceholder": "搜索作业...",
"status": "状态",
"allStatus": "全部状态",
"statusPending": "待提交",
"statusSubmitted": "已提交",
"statusGraded": "已批改"
},
"analytics": {
"examContent": "考试内容",
"questionPreview": "题目预览",
"errorAnalysis": "错误分析",
"errorRateOverview": "错误率概览",
"errorRateAriaLabel": "错误率 {{rate}}%",
"question": "题目",
"errors": "错误数",
"errorRateLabel": "错误率",
"wrongAnswersWithCount": "错答 ({{count}})",
"wrongAnswers": "错答",
"noWrongAnswers": "暂无错答记录。",
"studentAnswer": "学生答案",
"studentCount": "{{count}} 名学生",
"notAnswered": "未作答",
"selectQuestionHint": "请从左侧选择题目",
"selectQuestionHintDesc": "查看错误分析",
"noGradedSubmissions": "暂无已批改的提交。"
},
"scanViewer": {
"noImages": "该学生未上传答题图片",
"noImagesHint": "学生可在答题页拍摄上传纸质答案",
"zoomOut": "缩小",
"zoomIn": "放大",
"rotate": "旋转",
"fullscreen": "全屏",
"pageIndicator": "第 {{current}} / {{total}} 页",
"answerImageAlt": "答题图 第{{page}}页",
"thumbnailAlt": "缩略图 {{page}}"
},
"submissions": {
"title": "作业提交",
"description": "按作业查看提交与批改进度。",
"emptyDescription": "还没有可批改的作业。",
"quickAssignment": "快速作业",
"columns": {
"assignment": "作业"
}
},
"detail": {
"viewSubmissions": "查看提交",
"dueLabel": "截止时间",
"noDueDate": "无截止时间",
"submissionsLabel": "提交数",
"performanceAnalytics": "成绩分析",
"assignmentContent": "作业内容"
},
"excellent": {
"title": "优秀作业展示",
"description": "本作业中得分率达到 {{minPercentage}}% 及以上的优秀样例。",
"empty": "暂无符合条件的优秀作业。",
"emptyHint": "完成批改后将自动汇总展示。",
"loading": "加载优秀作业...",
"loadFailed": "加载失败",
"retry": "重试",
"rank": "第 {{rank}} 名",
"scoreLabel": "得分",
"scoreValue": "{{score}} / {{max}}",
"percentage": "{{value}}%",
"lateTag": "迟交",
"viewDetail": "查看详情",
"submittedAt": "提交于 {{date}}",
"studentAnon": "同学"
},
"parentExam": {
"examsTaken": "已参加考试",
"averageScore": "平均分",
"bestScore": "最高分",
"examResults": "{{name}} 的考试成绩",
"examResultsDescription": "近期考试分数与表现趋势",
"noResults": "暂无考试成绩",
"noResultsHint": "考试成绩将在批改完成后显示。",
"pass": "及格",
"belowPass": "不及格",
"viewGrades": "查看成绩详情"
}
},
"proctoring": {
"page": {
"noPermission": "您没有监考权限。",
"title": "考试监考",
"description": "监控考试期间的学生活动。"
},
"mode": {
"title": "考试模式",
"description": "选择考试模式并配置相关选项。监考模式会启用防作弊监控。",

View File

@@ -1,4 +1,88 @@
{
"title": "文件管理",
"description": "查看与管理系统中所有上传文件。"
}
"description": "查看与管理系统中所有上传文件。",
"upload": {
"title": "点击上传或拖拽到此处",
"hint": "支持图片、PDF、Word、Excel、PPT、文本、ZIP / RAR · 单文件最大 {size}",
"success": "{name} 上传成功",
"error": "{name}{message}",
"empty": "文件为空",
"tooLarge": "文件大小超过 {limit} 限制",
"invalidType": "不允许的文件类型 {type}",
"uploaded": "已上传",
"remove": "移除",
"networkError": "网络错误",
"invalidResponse": "响应格式错误"
},
"list": {
"empty": "暂无文件",
"emptyDescription": "当前没有可显示的文件。",
"download": "下载",
"delete": "删除",
"deleted": "文件已删除",
"deleteFailed": "删除文件失败"
},
"preview": {
"trigger": "预览",
"title": "文件预览",
"download": "下载",
"zoomIn": "放大",
"zoomOut": "缩小",
"office": {
"title": "Office 文件暂不支持在线预览",
"hint": "请下载文件后使用 Office 应用查看。"
},
"other": {
"title": "暂不支持预览",
"hint": "请下载文件查看内容。"
},
"text": {
"title": "文本文件",
"hint": "点击下方按钮加载内容预览。",
"load": "加载预览",
"loading": "加载中...",
"error": "加载文本失败:{message}"
}
},
"admin": {
"title": "文件管理",
"subtitle": "上传并管理系统中所有文件。",
"stats": {
"totalFiles": "文件总数",
"totalSize": "总大小"
},
"filter": {
"byType": "按类型筛选",
"search": "按文件名搜索...",
"allTypes": "全部类型",
"images": "图片",
"pdf": "PDF",
"word": "Word",
"wordDocx": "Word (docx)",
"excel": "Excel",
"excelXlsx": "Excel (xlsx)",
"powerpoint": "PowerPoint",
"powerpointPptx": "PowerPoint (pptx)",
"text": "文本",
"zip": "ZIP"
},
"selection": {
"selected": "已选 {count} 项",
"deleteSelected": "删除所选",
"deleting": "删除中...",
"deleted": "已删除 {count} 个文件",
"deleteFailed": "批量删除文件失败"
},
"empty": {
"title": "未找到文件",
"description": "请调整筛选条件或上传新文件。"
},
"columns": {
"file": "文件",
"size": "大小",
"type": "类型",
"uploaded": "上传时间",
"actions": "操作"
}
}
}

View File

@@ -256,6 +256,9 @@
"pasteHint": "提示:可从 Excel 复制多行多列分数,粘贴到表格中批量填充。按 Enter 跳到下一行Tab 跳到下一格。",
"confirmSwitchExam": "切换试卷将清空已录入的分数,确认切换?",
"confirmSwitchClass": "切换班级将清空已录入的分数,确认切换?",
"confirmTitle": "确认切换",
"confirmAction": "确认切换",
"cancelAction": "取消",
"saveAll": "保存全部成绩",
"saving": "保存中...",
"clear": "清空",
@@ -331,7 +334,9 @@
"ariaLabelEmpty": "分数分布柱状图:暂无数据",
"ariaLabelNonEmpty": "分数分布柱状图:共 {count} 条成绩记录分布在 5 个分数区间",
"tooltipStudents": "{count} 名学生",
"tooltipOfTotal": "占总数的 {percentage}%"
"tooltipOfTotal": "占总数的 {percentage}%",
"yourPosition": "你的位置:{score} 分({bucket} 区间)",
"yourPositionAriaLabel": "你的位置:最近成绩 {score} 分,对应高亮区间"
},
"subjectComparison": {
"descriptionEmpty": "对比所选班级各科目的表现。",
@@ -359,7 +364,17 @@
"significanceAriaLabel": "显著性分析:{level}",
"significanceDetails": "查看详细分析",
"significanceTopClass": "最高分班级:{name}{score} 分)",
"significanceBottomClass": "最低分班级:{name}{score} 分)"
"significanceBottomClass": "最低分班级:{name}{score} 分)",
"significanceStats": "p = {pValue} · Cohen's d = {cohensD}{effectSize}",
"significanceMethod": "Welch's t-test{significant}",
"significantYes": "差异具有统计显著性p < 0.05",
"significantNo": "差异不具统计显著性p ≥ 0.05",
"effectSize": {
"negligible": "可忽略",
"small": "小效应",
"medium": "中等效应",
"large": "大效应"
}
},
"trendChart": {
"descriptionEmpty": "随时间变化的分数趋势(标准化为 0-100。",
@@ -412,5 +427,317 @@
"recentGrades": "最近成绩",
"viewAll": "查看全部",
"noRecentGrades": "暂无最近成绩"
},
"page": {
"list": {
"description": "管理学生成绩记录。",
"stats": "统计",
"batchEntry": "批量录入",
"enterGrades": "录入成绩",
"emptyTitle": "暂无成绩记录",
"emptyDescription": "开始为您的班级录入成绩。",
"emptyActionLabel": "录入成绩",
"recordUnit": "条记录"
},
"analytics": {
"description": "趋势分析、班级对比与分数分布。",
"backToGrades": "返回成绩列表",
"noClassesTitle": "暂无班级",
"noClassesDescription": "您还没有任何班级。",
"trendTitle": "成绩趋势",
"trendEmptyTitle": "暂无趋势数据",
"trendEmptyDescription": "当前筛选条件下没有可显示的成绩趋势。",
"distributionTitle": "分数分布",
"distributionEmptyTitle": "暂无分布数据",
"distributionEmptyDescription": "当前筛选条件下没有可显示的分数分布。",
"subjectComparisonTitle": "科目对比",
"subjectComparisonEmptyTitle": "暂无科目对比数据",
"subjectComparisonEmptyDescription": "当前筛选条件下没有可显示的科目对比。",
"classComparisonTitle": "班级对比",
"classComparisonEmptyTitle": "暂无班级对比数据",
"classComparisonEmptyDescription": "当前筛选条件下没有可显示的班级对比。"
},
"entry": {
"title": "批量录入成绩",
"description": "从试卷库选择试卷,按每题得分录入,像填 Excel 表格一样。",
"noExamsTitle": "没有可用的试卷",
"noExamsDescription": "请先在试卷管理中创建试卷并添加题目,才能录入成绩。"
},
"stats": {
"description": "查看班级成绩统计和排名。",
"noClassesTitle": "暂无班级",
"noClassesDescription": "您还没有任何班级。",
"noAccessTitle": "暂无可访问的班级",
"noAccessDescription": "您没有权限查看任何班级。",
"exportLabel": "导出成绩"
},
"error": {
"title": "成绩页面加载失败",
"description": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"retry": "重试"
}
},
"schoolWide": {
"gradeCount": "年级数",
"classStudentInfo": "{classCount} 个班级 · {studentCount} 名学生",
"averageScore": "总体均分",
"recordCount": "基于 {count} 条成绩记录",
"passRate": "及格率",
"passLine": "及格线 60%",
"excellentRate": "优秀率",
"excellentLine": "优秀线 85%",
"comparisonTitle": "各年级成绩对比",
"comparisonDescription": "按年级聚合的平均分、及格率与优秀率对比。",
"empty": "暂无年级成绩数据",
"columns": {
"schoolGrade": "学校 / 年级",
"classCount": "班级数",
"studentCount": "学生数",
"recordCount": "记录数",
"average": "平均分",
"passRate": "及格率",
"excellentRate": "优秀率"
}
},
"action": {
"unknownSubject": "未知科目",
"scoreEntered": "已录入",
"notificationTitle": "成绩已录入:{title}",
"studentNotification": "您的{subject}成绩{score},请查看详情。",
"parentNotification": "您的孩子的{subject}成绩{score},请查看详情。",
"missingExamId": "缺少试卷 ID",
"missingClassId": "缺少班级 ID",
"missingRecords": "缺少成绩数据",
"missingRecordsData": "缺少成绩数据",
"examNotFound": "试卷不存在或无权访问",
"examNoQuestions": "试卷没有题目,无法录入",
"examNoSubject": "试卷未关联科目",
"invalidFormData": "表单数据无效",
"invalidId": "ID 无效",
"invalidQuery": "查询参数无效",
"recordCreated": "成绩记录已创建",
"recordUpdated": "成绩记录已更新",
"recordDeleted": "成绩记录已删除",
"recordsCreated": "录入 {count} 条成绩记录",
"recordsDeletedCount": "已删除 {count} 条成绩记录",
"recordsUndoneCount": "已撤销 {count} 条成绩记录",
"noRecordsToUndo": "无可撤销的记录",
"noRecordsToDelete": "无可删除的记录",
"cannotUndoMoreThan": "单次最多撤销 500 条记录",
"cannotDeleteMoreThan": "单次最多删除 500 条记录",
"ownGradesOnly": "只能查看自己的成绩",
"childrenGradesOnly": "只能查看子女的成绩",
"ownRecordsOnly": "只能查看自己的成绩记录",
"childrenRecordsOnly": "只能查看子女的成绩记录",
"ownRankingTrendOnly": "只能查看自己的排名趋势",
"childrenRankingTrendOnly": "只能查看子女的排名趋势",
"taughtClassesOnly": "只能查看所教班级的成绩记录",
"studentOnly": "此操作仅限学生本人调用",
"invalidJson": "成绩数据格式无效",
"exportOwnChildrenOnly": "只能导出自己子女的成绩",
"classIdRequired": "导出班级成绩需要班级 ID",
"filenameDetail": "成绩单_{date}.xlsx",
"filenameClassReport": "班级成绩总表_{date}.xlsx",
"errorExamNoQuestions": "试卷没有题目,无法录入成绩",
"errorQuestionNotInExam": "题目 {questionId} 不属于试卷 {examId}",
"scopeError": {
"class_taught": "只能访问您所教的班级",
"class_members": "只能访问您所在的班级",
"denied": "您的权限范围不允许访问"
}
},
"anomaly": {
"severityWarning": "警告",
"severityCritical": "严重",
"notificationTitle": "{subject}成绩异常预警",
"notificationContent": "{subject}成绩出现{severity}程度下降:最近成绩 {latestScore} 分,历史均分 {historicalAverage} 分,下降 {drop} 分。建议关注学生学习状态。"
},
"growthArchive": {
"title": "成长档案",
"description": "跨越 {years} 个学年、{records} 条成绩记录、{subjects} 个科目",
"descriptionEmpty": "跨学年/学期的纵向成长趋势",
"emptyTitle": "暂无成长档案数据",
"emptyDescription": "需先在成绩记录中关联学年信息后才能生成成长档案。",
"ariaLabelEmpty": "成长档案图表:暂无数据",
"ariaLabelNonEmpty": "成长档案图表:共 {count} 个学年/学期数据点",
"averageScore": "归一化平均分",
"overallAverage": "总均分 {score}",
"deltaUp": "整体提升 {delta} 分",
"deltaDown": "整体下降 {delta} 分",
"deltaStable": "成绩保持稳定",
"statsAverage": "均分:{score}",
"statsPassRate": "及格率:{rate}%",
"statsRecords": "{records} 条记录 · {subjects} 个科目"
},
"knowledgePointMastery": {
"title": "知识点掌握度",
"description": "共 {count} 个知识点 · 平均掌握度 {avg}%",
"descriptionEmpty": "基于诊断模块的知识点掌握度分析",
"emptyTitle": "暂无知识点掌握度数据",
"emptyDescription": "需先在诊断模块生成诊断报告后才能查看知识点掌握度。",
"ariaLabel": "知识点掌握度图表:共 {count} 个知识点",
"averageMastery": "平均掌握度",
"viewDetail": "查看诊断详情",
"tooltipMastery": "掌握度:{score}%",
"tooltipStudents": "已掌握 {mastered} / {total} 人",
"weakPointsTitle": "薄弱知识点(前 3 项)",
"weakPointsAriaLabel": "薄弱知识点列表"
},
"reportCard": {
"title": "学期成绩报告卡",
"schoolName": "智慧校园",
"periodLabel": "学年:{year} · 学期:{semester}",
"allSemesters": "全部学期",
"studentName": "学生姓名",
"className": "班级",
"classTeacher": "班主任",
"generatedAt": "生成日期",
"gradesSectionTitle": "各科成绩明细",
"summarySectionTitle": "综合统计",
"commentsTitle": "教师评语",
"commentsPlaceholder": "(此处由班主任填写评语)",
"commentsAriaLabel": "教师评语填写区",
"columnSubject": "科目",
"columnAssessment": "评估名称",
"columnType": "类型",
"columnScore": "得分",
"columnRank": "排名",
"columnRemark": "备注",
"subjectAvg": "均分 {score}",
"overallAverage": "总平均分",
"overallRank": "班级排名",
"passRate": "及格率",
"excellentRate": "优秀率",
"noRecords": "暂无记录",
"emptyGrades": "本周期内暂无成绩记录。",
"signatureClassTeacher": "班主任签名",
"signatureParent": "家长签名",
"signaturePrincipal": "校长签名",
"footerNote": "本报告卡由系统自动生成 · 生成日期 {date}",
"ariaLabel": "{name} 的学期成绩报告卡",
"backToGrades": "返回成绩查询",
"print": "打印 / 另存为 PDF",
"preparing": "准备打印中…",
"errorPrint": "打印失败,请重试",
"errorNavigate": "跳转报告卡页面失败",
"openReportCard": "打开成绩报告卡",
"filterAcademicYear": "学年",
"filterSemester": "学期",
"filterAllAcademicYears": "全部学年",
"filterAllSemesters": "全部学期",
"missingStudentTitle": "缺少学生信息",
"missingStudentDescription": "请通过成绩列表进入报告卡页面。",
"emptyTitle": "暂无成绩报告卡数据",
"emptyDescription": "当前筛选条件下无成绩数据,请调整筛选条件后重试。",
"loading": "正在生成成绩报告卡…",
"errorTitle": "加载失败",
"errorDescription": "成绩报告卡加载失败,请稍后重试。",
"retry": "重试",
"academicYearsCount": "可选学年共 {count} 个"
},
"excelImport": {
"triggerButton": "Excel 导入",
"dialogTitle": "批量导入成绩",
"dialogDescription": "通过 Excel 文件批量导入班级成绩。请先下载模板,填写后再上传。",
"step1Title": "第 1 步:下载导入模板",
"step1Description": "模板包含班级学生姓名示例,便于准确填写。",
"downloadTemplate": "下载模板",
"templateFilename": "成绩导入模板_{date}.xlsx",
"templateDownloaded": "模板已下载",
"step2Title": "第 2 步:填写录入参数",
"step3Title": "第 3 步:上传 Excel 文件",
"assessmentTitle": "评估标题",
"assessmentTitlePlaceholder": "如2026 春季期中考试",
"fullScore": "满分",
"examId": "关联试卷 ID选填",
"examIdPlaceholder": "如exam_xxx",
"fileLabel": "Excel 文件",
"fileHint": "支持 .xlsx / .xls 格式,单次最多 500 行",
"startImport": "开始导入",
"importing": "导入中…",
"cancel": "取消",
"resultTitle": "导入结果",
"resultSuccess": "成功导入",
"resultFailed": "失败",
"unmatchedStudents": "未匹配学生:",
"columnRow": "行",
"columnStudentName": "学生姓名",
"columnScore": "得分",
"columnErrors": "错误",
"moreErrors": "还有 {count} 行错误未显示",
"successMessage": "成功导入 {success} 条,失败 {failed} 条",
"successAllImported": "全部 {count} 条记录已成功导入",
"successPartial": "成功导入 {success} 条,失败 {failed} 条,请查看明细",
"errorNoFile": "请选择 Excel 文件",
"errorInvalidFormat": "仅支持 .xlsx 和 .xls 文件",
"errorEmptyFile": "文件中无数据",
"errorTooManyRows": "文件包含 {count} 行数据,超过单次最多 500 行的限制",
"errorInvalidParams": "参数校验失败",
"errorMissingFields": "请填写班级、科目和评估标题",
"errorSelectClass": "请先选择班级",
"errorTemplateDownload": "模板下载失败,请重试",
"errorImport": "导入失败,请重试",
"errorAllInvalid": "全部 {count} 行数据均无效"
},
"collabLock": {
"lockConflictTitle": "该班级+科目+类型正被其他教师编辑",
"lockConflictDescription": "{teacherName} 正在录入成绩,请稍后再试或联系对方让出编辑权。",
"lockConflictByYou": "您已持有该锁,可继续编辑。",
"lockConflictUnknownTeacher": "未知教师",
"acquired": "已获取编辑锁",
"acquireFailed": "获取编辑锁失败",
"renewed": "编辑锁已续期",
"renewFailed": "编辑锁续期失败,可能已被他人抢占",
"released": "编辑锁已释放",
"releaseFailed": "释放编辑锁失败",
"expiredTitle": "编辑锁已过期",
"expiredDescription": "您长时间未操作,编辑锁已过期并被自动释放,请重新进入页面以避免覆盖他人编辑。",
"retryAcquire": "重新获取锁",
"lockInfoTooltip": "当前编辑会话受锁保护5 分钟无操作自动释放,其他教师将无法同时编辑。",
"heartbeatError": "心跳续期失败,请检查网络后重新进入页面",
"noLockHolder": "无锁持有者"
},
"appeal": {
"title": "成绩申诉",
"description": "对成绩有异议可提交申诉,教师复核后会反馈结果。",
"submitAppeal": "提交申诉",
"reasonLabel": "申诉理由",
"reasonPlaceholder": "请详细说明申诉理由(至少 10 字)…",
"expectedScoreLabel": "期望分数(选填)",
"expectedScorePlaceholder": "如85",
"originalScoreLabel": "原分数",
"status": {
"pending": "待复核",
"approved": "已通过",
"rejected": "已驳回",
"withdrawn": "已撤回"
},
"reviewCommentLabel": "复核意见",
"adjustedScoreLabel": "调整后分数",
"reviewerLabel": "复核教师",
"reviewedAtLabel": "复核时间",
"createdAtLabel": "申诉时间",
"withdraw": "撤回申诉",
"withdrawConfirm": "确定要撤回此申诉吗?撤回后不可恢复。",
"reviewAppeal": "复核申诉",
"approve": "通过",
"reject": "驳回",
"reviewCommentPlaceholder": "请填写复核意见…",
"adjustScoreHint": "如需调整分数请填写,否则留空维持原分数",
"empty": "暂无申诉记录",
"pendingEmpty": "暂无待复核的申诉",
"gradeNotFound": "成绩记录不存在",
"notYourGrade": "只能对自己的成绩提出申诉",
"pendingExists": "该成绩已有待复核的申诉,请等待处理",
"notFound": "申诉记录不存在",
"outOfScope": "无权复核此班级的申诉",
"created": "申诉已提交,请等待教师复核",
"approved": "申诉已通过,成绩已更新",
"rejected": "申诉已驳回",
"withdrawn": "申诉已撤回",
"viewAppeals": "查看申诉",
"appealFor": "{title} 申诉",
"originalScoreSnapshot": "申诉时分数",
"currentScore": "当前分数"
}
}

View File

@@ -0,0 +1,80 @@
{
"title": "邀请码管理",
"description": "批量生成邀请码替代开放注册,支持预分配角色与班级。",
"roles": {
"student": "学生",
"teacher": "教师",
"parent": "家长",
"admin": "管理员"
},
"stats": {
"total": "邀请码总数",
"unused": "未使用",
"used": "已使用",
"expired": "已过期"
},
"generate": {
"title": "生成邀请码",
"description": "批量生成邀请码,可指定角色、限定邮箱、绑定班级或设置过期时间。",
"count": "生成数量",
"countDescription": "1-100 张/批",
"role": "角色",
"email": "限定邮箱(选填)",
"emailDescription": "留空则任意邮箱可用;填写后仅该邮箱可使用",
"classId": "班级 ID选填",
"classIdDescription": "填写后学生注册时自动加入此班级",
"expiresAt": "过期时间(选填)",
"expiresAtDescription": "留空则永久有效;支持 ISO 字符串(如 2026-12-31T23:59:59Z",
"notes": "备注(选填)",
"notesDescription": "仅管理员可见,用于记录批次用途",
"submit": "生成邀请码",
"generating": "生成中...",
"success": "成功生成 {count} 张邀请码",
"failed": "生成失败"
},
"columns": {
"code": "邀请码",
"role": "角色",
"email": "限定邮箱",
"classId": "班级",
"expiresAt": "过期时间",
"status": "状态",
"createdAt": "创建时间",
"usedBy": "使用者",
"usedAt": "使用时间",
"actions": "操作"
},
"status": {
"unused": "未使用",
"used": "已使用",
"expired": "已过期"
},
"actions": {
"copy": "复制",
"copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除",
"deleteConfirmTitle": "确认删除邀请码?",
"deleteConfirmDescription": "已使用的邀请码不可删除(保留作为审计记录)。仅未使用或已过期的邀请码可被删除。",
"cancel": "取消",
"deleting": "删除中...",
"confirmDelete": "确认删除",
"deleted": "邀请码已删除",
"deleteFailedUsed": "已使用的邀请码不可删除"
},
"empty": {
"title": "暂无邀请码",
"description": "点击「生成邀请码」批量创建,或在用户注册时引导填写邀请码。"
},
"generatedList": {
"title": "本批次生成的邀请码",
"description": "请妥善保存以下邀请码明文,关闭此对话框后将不再显示完整明文。",
"copyAll": "复制全部",
"close": "关闭",
"batchId": "批次 ID"
},
"errors": {
"loadFailed": "加载邀请码列表失败",
"permissionDenied": "无权限访问此页面"
}
}

View File

@@ -1,6 +1,12 @@
{
"title": "请假申请",
"title.parent": "在线请假",
"title.teacher": "请假审批",
"title.student": "我的请假",
"description": "为您的孩子提交请假申请。",
"description.parent": "为子女提交请假申请,班主任审批后自动同步考勤。",
"description.teacher": "查看并审批本班学生提交的请假申请,审批通过后自动将请假期间考勤标记为「请假」。",
"description.student": "提交本人请假申请,班主任审批后生效。",
"backToDashboard": "返回仪表盘",
"onlineLeave": "在线请假申请",
"comingSoon": "即将上线",
@@ -8,5 +14,68 @@
"contactOptions": "联系方式",
"callOffice": "在工作时间致电学校办公室",
"sendMessage": "通过消息页面向班主任发送消息",
"goToMessages": "前往消息"
}
"goToMessages": "前往消息",
"types": {
"sick": "病假",
"personal": "事假",
"family": "家庭事务",
"other": "其他"
},
"status": {
"pending": "待审批",
"approved": "已批准",
"rejected": "已拒绝",
"cancelled": "已撤销"
},
"form": {
"student": "请假学生",
"studentPlaceholder": "请选择子女",
"leaveType": "请假类型",
"dateRange": "请假日期",
"reason": "请假事由",
"reasonPlaceholder": "请简要说明请假原因(最多 500 字)",
"submit": "提交申请",
"submitting": "提交中…"
},
"list": {
"reason": "事由",
"reviewedBy": "审批人 {name}{date}",
"reviewComment": "审批意见",
"attendanceSynced": "考勤已同步"
},
"review": {
"title": "审批请假申请",
"description": "{name} · {className} · {dateRange}",
"approve": "批准",
"reject": "拒绝",
"cancel": "取消",
"commentLabel": "审批意见",
"commentPlaceholder": "请输入审批意见(拒绝时必填,批准时可选)",
"openButton": "审批"
},
"errors": {
"invalidForm": "表单填写有误,请检查后重试",
"noRelation": "您与该学生无家长-子女关系,无法代为请假",
"classMismatch": "所选班级与该学生当前所在班级不一致",
"overlappingLeave": "该日期范围内已存在未结束的请假申请",
"reviewCommentRequired": "拒绝请假时必须填写审批意见",
"notFound": "请假申请不存在或无权访问",
"alreadyReviewed": "该请假申请已被审批,无法重复操作",
"notOwner": "只能撤销本人提交的请假申请",
"cannotCancel": "当前状态不允许撤销(仅待审批状态可撤销)"
},
"messages": {
"created": "请假申请已提交,等待班主任审批",
"approved": "请假申请已批准,考勤记录已同步",
"rejected": "请假申请已拒绝",
"cancelled": "请假申请已撤销"
},
"empty": {
"parentTitle": "暂无请假记录",
"parentDesc": "您尚未为子女提交任何请假申请",
"teacherTitle": "暂无请假申请",
"teacherDesc": "本班学生暂无待审批或已审批的请假申请",
"studentTitle": "暂无请假记录",
"studentDesc": "您尚未提交任何请假申请"
}
}

View File

@@ -34,7 +34,10 @@
"unsaved": "未保存",
"saved": "已保存",
"draft": "草稿",
"submitted": "待审核",
"approved": "审核通过",
"published": "已发布",
"rejected": "已驳回",
"archived": "已归档",
"publishedAsHomework": "已发布为作业"
},
@@ -235,6 +238,7 @@
"typeLabel": "题型",
"stemLabel": "题干",
"optionsLabel": "选项(勾选正确答案)",
"optionLabel": "选项 {index}",
"addOption": "+ 添加选项",
"correctAnswer": "正确答案",
"correct": "正确",
@@ -395,5 +399,198 @@
"confirm": {
"archive": "确认归档此课案?",
"archiveTitle": "确认归档"
},
"calendar": {
"title": "备课日历",
"description": "按日期查看备课活动",
"weekView": "周视图",
"monthView": "月视图",
"today": "今天",
"prev": "上一页",
"next": "下一页",
"noEvents": "当日无备课活动",
"loading": "加载中...",
"loadFailed": "加载日历失败",
"eventType": {
"created": "创建",
"updated": "更新",
"version_saved": "保存版本",
"submitted": "提交审核",
"published": "发布",
"reviewed": "审核"
},
"eventMeta": "v{versionNo}",
"weekDays": {
"sun": "周日",
"mon": "周一",
"tue": "周二",
"wed": "周三",
"thu": "周四",
"fri": "周五",
"sat": "周六"
}
},
"review": {
"title": "审核工作流",
"submit": "提交审核",
"withdraw": "撤回提交",
"approve": "通过",
"reject": "驳回",
"submitConfirm": "确认提交审核?提交后不可编辑,等待教研组长审核。",
"withdrawConfirm": "确认撤回提交?撤回后将回到草稿状态。",
"approveConfirm": "确认通过此课案审核?",
"rejectConfirm": "确认驳回此课案?教师可修改后重新提交。",
"commentLabel": "审核意见",
"commentPlaceholder": "请输入审核意见(可选)",
"submitted": "已提交审核",
"approved": "审核通过",
"rejected": "审核驳回",
"withdrawn": "已撤回",
"reviewerLabel": "审核人:{name}",
"reviewAt": "审核时间:{time}",
"records": "审核记录",
"noRecords": "暂无审核记录",
"pendingTitle": "待审核课案",
"pendingEmpty": "暂无待审核课案",
"invalidTransition": "状态变更不合法",
"notSubmitted": "当前状态不可提交审核",
"notReviewable": "当前状态不可审核",
"submitSuccess": "已提交审核",
"withdrawSuccess": "已撤回提交",
"approveSuccess": "审核已通过",
"rejectSuccess": "已驳回"
},
"comment": {
"title": "协同评论",
"add": "发表评论",
"reply": "回复",
"placeholder": "输入评论内容...",
"empty": "暂无评论",
"resolve": "标记已解决",
"reopen": "重新打开",
"resolved": "已解决",
"unresolved": "未解决",
"delete": "删除",
"deleteConfirm": "确认删除此评论?子回复将一并删除。",
"edit": "编辑",
"unresolvedCount": "{count} 条未解决",
"blockLabel": "节点:{title}",
"createSuccess": "评论已发表",
"deleteSuccess": "已删除",
"resolveSuccess": "已标记为解决",
"createFailed": "发表评论失败",
"deleteFailed": "删除评论失败"
},
"formative": {
"title": "形成性评价",
"add": "添加互动组件",
"empty": "暂无互动组件",
"interactionType": {
"poll": "投票",
"quiz": "随堂测",
"exit_ticket": "出口票"
},
"promptLabel": "题目",
"promptPlaceholder": "输入互动题目...",
"optionsLabel": "选项",
"addOption": "+ 添加选项",
"correctAnswer": "正确答案",
"durationLabel": "时长(秒)",
"responses": "学生作答",
"noResponses": "暂无作答",
"stats": "统计",
"totalResponses": "{count} 人作答",
"correctRate": "正确率 {rate}%",
"avgDuration": "平均用时 {sec} 秒",
"createSuccess": "已添加",
"updateSuccess": "已更新",
"deleteSuccess": "已删除"
},
"attachment": {
"title": "资源附件",
"add": "添加附件",
"empty": "暂无附件",
"type": {
"reference": "参考资料",
"material": "教学素材",
"supplementary": "补充材料"
},
"nameLabel": "名称",
"urlLabel": "链接",
"typeLabel": "类型",
"createSuccess": "附件已添加",
"deleteSuccess": "附件已删除",
"updateSuccess": "附件已更新"
},
"substitute": {
"title": "代课教师",
"add": "添加代课",
"empty": "暂无代课记录",
"originalTeacher": "原任教师",
"substituteTeacher": "代课教师",
"startDate": "开始日期",
"endDate": "结束日期",
"status": {
"active": "生效中",
"expired": "已过期",
"cancelled": "已取消"
},
"createSuccess": "代课已安排",
"updateSuccess": "代课状态已更新",
"deleteSuccess": "代课已删除",
"cancelConfirm": "确认取消此代课安排?"
},
"evaluation": {
"title": "AI 课案评估",
"run": "开始评估",
"running": "评估中...",
"empty": "暂无评估记录",
"overallScore": "综合评分",
"dimensions": {
"clarity": "目标清晰度",
"alignment": "课标对齐",
"engagement": "学生参与",
"differentiation": "分层设计",
"assessment": "评估设计"
},
"suggestions": "改进建议",
"noSuggestions": "暂无改进建议",
"createSuccess": "评估完成",
"suggestion": {
"addMoreNodeDetails": "建议在教学节点中补充更多细节",
"linkMoreStandards": "建议关联更多课标条目",
"addFormativeItems": "建议增加课堂互动组件",
"addPracticeBlock": "建议增加课堂练习环节",
"addHomeworkBlock": "建议明确课后作业要求"
}
},
"analytics": {
"title": "备课分析",
"teacherInvestment": "教师投入",
"templateUsage": "模板使用",
"standardsCoverage": "课标覆盖",
"globalStats": "概览",
"totalTeachers": "参与教师",
"totalPlans": "课案总数",
"totalPublished": "已发布",
"totalSubmitted": "待审核",
"totalStandardsLinked": "已关联课标",
"noData": "暂无数据",
"loadFailed": "加载分析数据失败"
},
"standards": {
"title": "课标对标",
"link": "关联课标",
"unlink": "取消关联",
"linked": "已关联 {count} 条",
"empty": "暂无关联课标",
"searchPlaceholder": "搜索课标...",
"level": {
"national": "国家标准",
"curriculum": "课程标准",
"custom": "校本标准"
},
"linkSuccess": "已关联课标",
"unlinkSuccess": "已取消关联"
}
}

View File

@@ -4,11 +4,13 @@
"detail": "消息详情",
"compose": "撰写消息",
"reply": "回复",
"newMessage": "新消息"
"newMessage": "新消息",
"groupCompose": "群发消息"
},
"description": {
"list": "管理收件箱并随时掌握通知动态。",
"compose": "向其他用户发送消息。"
"compose": "向其他用户发送消息。",
"groupCompose": "向班级所有家长发送消息。"
},
"tabs": {
"inbox": "收件箱",
@@ -25,7 +27,11 @@
"star": "星标",
"unstar": "取消星标",
"saveDraft": "保存草稿",
"deleteDraft": "删除草稿"
"deleteDraft": "删除草稿",
"recall": "撤回",
"report": "举报",
"block": "屏蔽",
"unblock": "取消屏蔽"
},
"form": {
"to": "收件人",
@@ -34,7 +40,10 @@
"subjectPlaceholder": "消息主题",
"content": "内容",
"contentPlaceholder": "请输入消息内容...",
"selectRecipient": "请选择收件人"
"selectRecipient": "请选择收件人",
"class": "班级",
"classPlaceholder": "选择班级",
"selectClass": "请选择班级"
},
"status": {
"new": "新",
@@ -53,6 +62,71 @@
"placeholder": "按主题或内容搜索消息...",
"noResults": "未找到匹配的消息"
},
"pagination": {
"nav": "消息分页",
"previous": "上一页",
"next": "下一页",
"page": "第 {current} / {total} 页"
},
"drafts": {
"title": "草稿",
"resume": "继续编辑",
"delete": "删除草稿",
"noContent": "(无内容)",
"updatedAt": "更新于 {date}",
"versionConflict": "草稿已在其他设备更新,已为您同步最新版本",
"syncedFromOtherDevice": "已从其他设备同步草稿"
},
"templates": {
"title": "快捷回复模板",
"insert": "插入模板",
"manage": "管理模板",
"create": "新建模板",
"edit": "编辑",
"delete": "删除",
"empty": "暂无模板",
"emptyDesc": "创建常用回复模板以提升沟通效率。",
"titleField": "模板标题",
"titlePlaceholder": "如:已收到",
"contentField": "模板内容",
"contentPlaceholder": "如:已收到,感谢您的反馈。",
"sortOrder": "排序(越小越靠前)",
"saved": "模板已保存",
"deleted": "模板已删除",
"deleteConfirm": "确定删除此模板吗?"
},
"report": {
"title": "举报消息",
"description": "举报不当内容,管理员将进行审核处理。",
"reason": "举报理由",
"reasonSpam": "垃圾信息",
"reasonHarassment": "骚扰",
"reasonInappropriate": "不当内容",
"reasonOther": "其他",
"descriptionField": "详细描述(可选)",
"descriptionPlaceholder": "请补充举报理由...",
"confirm": "确定举报",
"cancel": "取消"
},
"block": {
"title": "屏蔽用户",
"confirm": "确定屏蔽此用户吗?屏蔽后双方将无法互发消息。",
"list": "屏蔽列表",
"empty": "暂无屏蔽用户"
},
"attachments": {
"add": "添加附件",
"remove": "移除附件",
"title": "附件({count}",
"download": "下载附件",
"empty": "无附件"
},
"thread": {
"title": "对话",
"replyCount": "{count} 条回复",
"noReplies": "暂无回复",
"you": "你"
},
"empty": {
"inboxEmpty": "收件箱为空",
"inboxEmptyDesc": "您还没有收到任何消息。",
@@ -75,7 +149,26 @@
"selectRecipient": "请选择收件人",
"draftSaved": "草稿已保存",
"draftDeleted": "草稿已删除",
"starToggled": "星标状态已更新"
"starToggled": "星标状态已更新",
"recalled": "消息已撤回",
"recallFailed": "撤回失败",
"recallExpired": "已超过 2 分钟撤回时限",
"recallConfirm": "确定撤回这条消息吗?",
"groupSent": "群发消息已发送给 {count} 位家长",
"groupNoRecipients": "该班级没有可接收消息的家长",
"groupNotAuthorized": "您无权向该班级群发消息",
"groupSendFailed": "群发消息失败",
"reported": "消息已举报",
"alreadyReported": "您已举报过此消息",
"reportFailed": "举报失败",
"reportSelf": "不能举报自己的消息",
"blocked": "用户已屏蔽",
"alreadyBlocked": "该用户已被屏蔽",
"blockSelf": "不能屏蔽自己",
"blockFailed": "屏蔽失败",
"unblocked": "已取消屏蔽",
"unblockFailed": "取消屏蔽失败",
"blockedSendHint": "对方已屏蔽您,无法发送消息"
},
"notification": {
"messageTitle": "新消息:{subject}",
@@ -84,6 +177,8 @@
"error": {
"loadFailed": "消息加载失败",
"loadFailedDesc": "抱歉,加载消息时发生了意外错误。请稍后重试。",
"retry": "重试"
"retry": "重试",
"boundaryTitle": "消息区块加载失败",
"boundaryDescription": "加载消息数据时发生错误,请重试。"
}
}

View File

@@ -16,6 +16,8 @@
"users": "用户",
"userList": "用户列表",
"importUsers": "导入用户",
"roles": "角色管理",
"permissions": "权限目录",
"teaching": "教学管理",
"coursePlans": "课程计划",
"electives": "选修课",

View File

@@ -18,7 +18,8 @@
"message": "消息",
"announcement": "公告",
"homework": "作业",
"grade": "成绩"
"grade": "成绩",
"diagnostic": "学情诊断"
},
"filter": {
"all": "全部"

View File

@@ -0,0 +1,22 @@
{
"childDetail": {
"defaultName": "子女",
"tabs": {
"overview": "概览",
"homework": "作业",
"grades": "成绩",
"exams": "考试",
"schedule": "课表",
"attendance": "考勤",
"diagnostic": "诊断"
},
"tabAriaLabel": "{label} 标签",
"subjectAnalysis": "科目分析",
"attendanceHint": "考勤详情可在<link>考勤页面</link>查看。",
"diagnosticHint": "学校发布的诊断报告将在此处显示。",
"contactTeacher": "联系老师",
"contactTeacherAria": "联系老师了解 {name} 的情况",
"switchChild": "切换子女",
"viewDetailsAria": "查看 {name} 的详情"
}
}

View File

@@ -38,7 +38,9 @@
"true": "正确",
"false": "错误",
"textPlaceholder": "请输入你的答案...",
"empty": "本次练习没有题目"
"empty": "本次练习没有题目",
"retry": "重试",
"submitFailedDescription": "提交失败,请检查网络后重试"
},
"result": {
"title": "练习完成",
@@ -56,7 +58,6 @@
"createFailed": "创建失败",
"selectKnowledgePoint": "请至少选择一个知识点",
"selectWeakKnowledgePoint": "请至少选择一个薄弱知识点",
"aiRecommendedReason": "学生自主发起 AI 推荐练习",
"submitted": "已提交",
"submitFailed": "提交失败",
"completed": "练习已完成",
@@ -71,7 +72,7 @@
"accuracy": "正确率"
},
"types": {
"error_variant": "错题变式",
"error_variant": "错题重做",
"knowledge_point": "知识点专项",
"weak_chapter": "薄弱章节",
"ai_recommended": "AI 推荐"
@@ -159,5 +160,69 @@
"title": "各班级练习对比",
"description": "按参与率降序排列,识别活跃班级与待提升班级"
}
},
"parent": {
"title": "子女专项练习",
"description": "查看子女的练习记录与统计,了解学习进度",
"childSelector": "选择子女",
"noChild": "暂无关联子女",
"noChildDescription": "您还未关联子女,请联系学校管理员",
"stats": "练习统计",
"history": "练习记录",
"noChildSelected": "请选择子女查看练习数据"
},
"errors": {
"sessionNotFound": "练习会话不存在或无权访问",
"sessionEnded": "练习会话已结束",
"answerNotFound": "答题记录不存在",
"answerAlreadySubmitted": "此题已作答",
"questionNotFound": "题目不存在",
"sessionNotComplete": "仍有未作答的题目,无法完成练习",
"invalidFormat": "提交格式错误,需要 JSON 字段",
"validationFailed": "输入验证失败",
"noQuestionsFound": "未找到符合条件的题目,请尝试其他筛选条件",
"fetchSessionsFailed": "获取练习列表失败",
"fetchDetailFailed": "获取练习详情失败",
"fetchStatsFailed": "获取练习统计失败",
"forbidden": "无权访问该学生的练习数据",
"loadFailed": "加载失败",
"loadFailedDescription": "请刷新页面重试,或联系管理员检查数据访问权限。",
"retry": "重试",
"pageErrorPractice": "加载练习分析数据时发生错误",
"pageErrorGrade": "加载年级练习数据时发生错误",
"pageErrorParent": "加载子女练习数据时发生错误",
"pageErrorTitlePractice": "专项练习分析",
"pageErrorTitleGrade": "年级专项练习总览",
"pageErrorTitleParent": "子女专项练习",
"session_not_found": "练习会话不存在或无权访问",
"session_ended": "练习会话已结束",
"answer_not_found": "答题记录不存在",
"answer_already_submitted": "此题已作答",
"question_not_found": "题目不存在",
"session_not_complete": "仍有未作答的题目,无法完成练习",
"no_questions_found": "未找到符合条件的题目,请尝试其他筛选条件",
"forbidden_scope": "无权访问该学生的练习数据",
"invalid_input": "提交格式错误,需要 JSON 字段",
"validation_error": "输入验证失败",
"invalid_source_meta": "来源元数据结构与练习类型不匹配"
},
"messages": {
"sessionCreated": "已创建练习会话,共 {count} 道题目",
"answerSubmitted": "答案已提交",
"answerCorrect": "回答正确",
"answerIncorrect": "回答错误",
"answerSkipped": "已跳过此题",
"sessionCompleted": "练习已完成",
"sessionAbandoned": "练习已放弃"
},
"reasons": {
"student_initiated": "学生自主发起 AI 推荐练习",
"teacher_assigned": "教师布置",
"parent_suggested": "家长建议"
},
"classFilter": {
"all": "全部班级",
"selectClass": "选择班级",
"sessionCount": "{count, plural, =0 {无练习} =1 {# 次练习} other {# 次练习}}"
}
}

View File

@@ -0,0 +1,156 @@
{
"title": "题库",
"subtitle": "管理考试与作业的题目仓库。",
"addQuestion": "添加题目",
"search": {
"placeholder": "搜索题目内容..."
},
"filters": {
"type": "题型",
"typeAll": "全部题型",
"difficulty": "难度",
"difficultyAll": "任意难度",
"difficulty1": "简单 (1)",
"difficulty2": "偏易 (2)",
"difficulty3": "中等 (3)",
"difficulty4": "偏难 (4)",
"difficulty5": "困难 (5)",
"knowledgePoint": "知识点",
"knowledgePointAll": "全部知识点",
"textbook": "教材",
"textbookAll": "全部教材",
"chapter": "章节",
"chapterAll": "全部章节",
"clear": "清除筛选"
},
"table": {
"type": "题型",
"content": "内容",
"difficulty": "难度",
"knowledgePoints": "知识点",
"created": "创建时间",
"actions": "操作",
"noResults": "暂无数据",
"selected": "已选 {count} 项,共 {total} 项",
"previous": "上一页",
"next": "下一页"
},
"type": {
"single_choice": "单选题",
"multiple_choice": "多选题",
"judgment": "判断题",
"text": "简答题",
"composite": "复合题"
},
"difficulty": {
"1": "简单",
"2": "偏易",
"3": "中等",
"4": "偏难",
"5": "困难"
},
"empty": {
"withFilters": "没有匹配的题目",
"withoutFilters": "暂无题目",
"withFiltersDesc": "请尝试清除筛选或调整关键词。",
"withoutFiltersDesc": "创建你的第一道题目以开始构建试卷和作业。"
},
"dialog": {
"createTitle": "创建新题目",
"editTitle": "编辑题目",
"createDesc": "向题库添加新题目,填写以下信息。",
"editDesc": "更新题目信息。",
"questionType": "题目类型",
"selectType": "选择题型",
"difficulty": "难度 (1-5)",
"selectDifficulty": "选择难度",
"questionContent": "题目内容",
"contentPlaceholder": "在此输入题目内容...",
"contentDescription": "支持基本文本,富文本编辑器即将上线。",
"knowledgePoints": "知识点",
"knowledgePointsOptional": "可选",
"knowledgePointsSelected": "已选 {count} 个",
"searchKnowledgePoints": "搜索知识点...",
"noKnowledgePoints": "未找到知识点。",
"loading": "加载中...",
"options": "选项",
"addOption": "添加选项",
"optionPlaceholder": "选项 {index}",
"markCorrect": "标记为正确答案",
"cancel": "取消",
"create": "创建题目",
"update": "更新题目",
"creating": "创建中...",
"updating": "更新中..."
},
"actions": {
"menuLabel": "打开菜单",
"actions": "操作",
"copyId": "复制 ID",
"viewDetails": "查看详情",
"edit": "编辑",
"delete": "删除",
"copyIdSuccess": "题目 ID 已复制到剪贴板",
"copyIdFailed": "复制题目 ID 失败",
"deleteSuccess": "题目删除成功",
"deleteFailed": "删除题目失败",
"deleteConfirmTitle": "确定要删除吗?",
"deleteConfirmDesc": "此操作不可撤销,将永久删除该题目并从服务器移除。",
"deleteConfirmCancel": "取消",
"deleteConfirmAction": "删除",
"deleting": "删除中...",
"detailsTitle": "题目详情",
"detailsId": "ID: {id}",
"detailsType": "题型",
"detailsDifficulty": "难度",
"detailsContent": "内容",
"detailsAuthor": "作者",
"detailsTags": "标签",
"detailsUnknown": "未知"
},
"error": {
"loadFailed": "题库加载失败",
"loadFailedDesc": "请稍后重试,或联系管理员。",
"retry": "重试",
"invalidFormat": "题目内容格式无效",
"invalidSubmission": "提交格式无效,应为 JSON。",
"validationFailed": "校验失败",
"missingId": "缺少题目 ID",
"operationFailed": "操作失败",
"unexpected": "发生意外错误",
"createdSuccess": "题目创建成功",
"updatedSuccess": "题目更新成功"
},
"noPermission": {
"title": "无权限",
"description": "您没有权限执行此操作。"
},
"batch": {
"selected": "已选 {count} 项",
"delete": "批量删除",
"clear": "清除选择",
"cancel": "取消",
"deleteConfirmTitle": "确认批量删除?",
"deleteConfirmDesc": "将删除 {count} 道题目及其子题,此操作不可撤销。",
"deleteConfirmAction": "确认删除",
"deleting": "删除中...",
"deleteSuccess": "成功删除 {count} 道题目",
"deleteFailed": "批量删除失败"
},
"importExport": {
"export": "导出",
"import": "导入",
"exporting": "导出中...",
"importing": "导入中...",
"exportSuccess": "成功导出 {count} 道题目",
"exportFailed": "导出失败",
"importSuccess": "成功导入 {count} 道题目",
"importFailed": "导入失败",
"invalidFile": "无效的文件格式",
"readFailed": "文件读取失败",
"confirmTitle": "确认导入",
"confirmDesc": "请确认以下内容是要导入的题目数据:",
"confirmImport": "确认导入",
"cancel": "取消"
}
}

View File

@@ -0,0 +1,318 @@
{
"roles": {
"title": "角色管理",
"description": "创建自定义角色并管理权限分配。",
"detail": "角色详情",
"create": "创建角色",
"edit": "编辑角色",
"delete": "删除角色",
"name": "角色名称",
"description": "描述",
"type": "类型",
"status": "状态",
"system": "系统",
"custom": "自定义",
"enabled": "已启用",
"disabled": "已禁用",
"permissions": "权限",
"users": "用户",
"updated": "更新时间",
"locked": "已锁定",
"adminLocked": "admin 角色已完全锁定,无法修改。",
"listTitle": "角色 ({count})",
"emptyTitle": "暂无角色",
"emptyDescription": "创建你的第一个自定义角色以开始。",
"editButton": "编辑角色",
"createTitle": "创建新角色",
"editRoleTitle": "编辑角色:{name}",
"createDescription": "创建自定义角色。创建后可为其分配权限。角色名称只能包含小写字母、数字和下划线。",
"editDescription": "更新角色名称或描述。权限分配在角色详情页管理。",
"namePlaceholder": "例如 teaching_assistant",
"namePatternTitle": "仅允许小写字母、数字和下划线",
"adminNameLocked": "admin 角色名称无法修改。",
"descriptionLabel": "描述(可选)",
"descriptionPlaceholder": "这个角色的用途是什么?",
"deleteConfirmTitle": "删除角色 \"{name}\"",
"deleteConfirmDescription": "将移除该角色并从 {count} 个用户身上撤销。仅分配了该角色的用户将失去所有访问权限。此操作无法撤销。",
"assignTitle": "分配角色",
"assignDescription": "为 <strong>{name}</strong> 选择角色。用户可能需要重新登录才能生效。",
"noEnabledRoles": "没有可用的已启用角色。请先创建或启用一个角色。",
"disabledAssignedLabel": "当前已分配但已禁用(保存时将被移除):",
"disabledLabel": "已禁用",
"saveRoles": "保存角色",
"openMenu": "打开菜单",
"tableAriaLabel": "角色列表",
"tableCaption": "共 {count} 个角色",
"assignListAriaLabel": "为 {name} 分配角色"
},
"permissions": {
"title": "权限目录",
"description": "系统中定义的所有权限点,按模块分组。",
"countLabel": "{count} 个权限",
"roleCount": "{count} 个角色",
"matrixTitle": "权限 — {roleName}",
"selectedCount": "已选 {count} 个",
"impactNotice": "此角色的权限变更将影响 {count} 个用户。",
"searchPlaceholder": "搜索权限名称或标识...",
"searchAriaLabel": "搜索权限",
"searchNoResults": "没有匹配的权限",
"expandGroup": "展开 {name} 分组",
"collapseGroup": "折叠 {name} 分组",
"catalogAriaLabel": "权限目录",
"group": {
"exam": "考试",
"homework": "作业",
"question": "题目",
"textbook": "教材",
"class": "班级",
"school": "学校",
"user": "用户",
"ai": "AI",
"settings": "设置",
"audit": "审计",
"announcement": "公告",
"grade_record": "成绩",
"file": "文件",
"course_plan": "课程计划",
"attendance": "考勤",
"message": "消息",
"scheduling": "排课",
"elective": "选课",
"diagnostic": "诊断",
"lesson_plan": "教案",
"dashboard": "仪表盘",
"error_book": "错题本",
"adaptive_practice": "专项练习",
"rbac": "角色与权限管理"
},
"exam": {
"create": "创建考试",
"create.desc": "允许创建新考试",
"read": "查看考试",
"read.desc": "允许查看考试列表和详情",
"update": "更新考试",
"update.desc": "允许修改考试信息",
"delete": "删除考试",
"delete.desc": "允许删除考试",
"duplicate": "复制考试",
"duplicate.desc": "允许复制现有考试",
"publish": "发布考试",
"publish.desc": "允许发布考试供学生作答",
"ai_generate": "AI 生成考试",
"ai_generate.desc": "允许使用 AI 生成考试内容",
"submit": "提交考试",
"submit.desc": "允许提交考试答卷",
"proctor": "监考",
"proctor.desc": "允许执行监考操作",
"proctor_read": "查看监考",
"proctor_read.desc": "允许查看监考信息"
},
"homework": {
"create": "创建作业",
"create.desc": "允许创建新作业",
"grade": "批改作业",
"grade.desc": "允许批改学生作业",
"submit": "提交作业",
"submit.desc": "允许提交作业"
},
"question": {
"create": "创建题目",
"create.desc": "允许创建新题目",
"read": "查看题目",
"read.desc": "允许查看题目列表和详情",
"update": "更新题目",
"update.desc": "允许修改题目信息",
"delete": "删除题目",
"delete.desc": "允许删除题目"
},
"textbook": {
"create": "创建教材",
"create.desc": "允许创建新教材",
"read": "查看教材",
"read.desc": "允许查看教材列表和详情",
"update": "更新教材",
"update.desc": "允许修改教材信息",
"delete": "删除教材",
"delete.desc": "允许删除教材"
},
"class": {
"create": "创建班级",
"create.desc": "允许创建新班级",
"read": "查看班级",
"read.desc": "允许查看班级列表和详情",
"update": "更新班级",
"update.desc": "允许修改班级信息",
"delete": "删除班级",
"delete.desc": "允许删除班级",
"enroll": "学生入班",
"enroll.desc": "允许管理班级学生入班",
"schedule": "班级排课",
"schedule.desc": "允许管理班级课程表"
},
"school": {
"manage": "学校管理",
"manage.desc": "允许管理学校信息",
"grade_manage": "年级管理",
"grade_manage.desc": "允许管理年级信息",
"user_manage": "用户管理",
"user_manage.desc": "允许管理系统用户"
},
"user": {
"profile_update": "更新个人资料",
"profile_update.desc": "允许更新用户个人资料"
},
"ai": {
"chat": "AI 对话",
"chat.desc": "允许使用 AI 对话功能",
"configure": "AI 配置",
"configure.desc": "允许配置 AI 参数"
},
"settings": {
"admin": "系统设置",
"admin.desc": "允许管理系统设置"
},
"audit": {
"read": "查看审计日志",
"read.desc": "允许查看系统审计日志"
},
"announcement": {
"manage": "管理公告",
"manage.desc": "允许创建、修改和删除公告",
"read": "查看公告",
"read.desc": "允许查看公告内容"
},
"grade_record": {
"manage": "管理成绩",
"manage.desc": "允许录入和修改成绩",
"read": "查看成绩",
"read.desc": "允许查看成绩记录"
},
"file": {
"upload": "上传文件",
"upload.desc": "允许上传文件",
"read": "查看文件",
"read.desc": "允许查看文件",
"delete": "删除文件",
"delete.desc": "允许删除文件"
},
"course_plan": {
"manage": "管理课程计划",
"manage.desc": "允许创建、修改和删除课程计划",
"read": "查看课程计划",
"read.desc": "允许查看课程计划"
},
"attendance": {
"manage": "管理考勤",
"manage.desc": "允许录入和修改考勤记录",
"read": "查看考勤",
"read.desc": "允许查看考勤记录"
},
"leave_request": {
"create": "提交请假申请",
"create.desc": "允许家长/学生提交在线请假申请",
"read": "查看请假申请",
"read.desc": "允许查看本人/子女/所辖班级的请假申请",
"review": "审批请假申请",
"review.desc": "允许班主任/管理员审批请假申请并自动同步考勤"
},
"message": {
"send": "发送消息",
"send.desc": "允许发送消息",
"read": "查看消息",
"read.desc": "允许查看消息",
"delete": "删除消息",
"delete.desc": "允许删除消息"
},
"scheduling": {
"auto": "自动排课",
"auto.desc": "允许执行自动排课",
"adjust": "调整排课",
"adjust.desc": "允许手动调整课程表"
},
"elective": {
"manage": "管理选课",
"manage.desc": "允许管理选课设置",
"read": "查看选课",
"read.desc": "允许查看选课信息",
"select": "选课",
"select.desc": "允许学生进行选课"
},
"diagnostic": {
"manage": "管理诊断",
"manage.desc": "允许管理诊断测试",
"read": "查看诊断",
"read.desc": "允许查看诊断结果"
},
"lesson_plan": {
"create": "创建教案",
"create.desc": "允许创建新教案",
"read": "查看教案",
"read.desc": "允许查看教案列表和详情",
"update": "更新教案",
"update.desc": "允许修改教案信息",
"delete": "删除教案",
"delete.desc": "允许删除教案",
"publish": "发布教案",
"publish.desc": "允许发布教案"
},
"dashboard": {
"admin_read": "查看管理员仪表盘",
"admin_read.desc": "允许查看管理员仪表盘",
"teacher_read": "查看教师仪表盘",
"teacher_read.desc": "允许查看教师仪表盘",
"student_read": "查看学生仪表盘",
"student_read.desc": "允许查看学生仪表盘",
"parent_read": "查看家长仪表盘",
"parent_read.desc": "允许查看家长仪表盘"
},
"error_book": {
"read": "查看错题本",
"read.desc": "允许查看错题本",
"manage": "管理错题本",
"manage.desc": "允许管理错题记录",
"analytics_read": "查看错题分析",
"analytics_read.desc": "允许查看错题本分析数据"
},
"adaptive_practice": {
"read": "查看专项练习",
"read.desc": "允许查看专项练习",
"manage": "管理专项练习",
"manage.desc": "允许管理专项练习"
},
"rbac": {
"role_create": "创建角色",
"role_create.desc": "允许创建新角色",
"role_read": "查看角色",
"role_read.desc": "允许查看角色列表和详情",
"role_update": "更新角色",
"role_update.desc": "允许修改角色信息",
"role_delete": "删除角色",
"role_delete.desc": "允许删除角色",
"role_assign": "分配角色",
"role_assign.desc": "允许为用户分配角色",
"permission_read": "查看权限",
"permission_read.desc": "允许查看权限目录"
}
},
"actions": {
"save": "保存更改",
"cancel": "取消",
"delete": "删除",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"assignRoles": "分配角色",
"saving": "保存中...",
"deleting": "删除中..."
},
"messages": {
"created": "角色已创建",
"updated": "角色已更新",
"deleted": "角色已删除",
"permissionsUpdated": "权限已更新",
"rolesAssigned": "角色已分配。用户可能需要重新登录才能生效。",
"roleEnabled": "角色已启用",
"roleDisabled": "角色已禁用",
"roleStatusUpdated": "角色状态已更新"
}
}

View File

@@ -40,7 +40,44 @@
"save": "保存修改",
"saving": "保存中...",
"success": "个人资料更新成功",
"failure": "个人资料更新失败"
"failure": "个人资料更新失败",
"page": {
"title": "个人资料",
"description": "管理您的个人和账户信息。",
"editProfile": "编辑资料",
"personalInfo": {
"title": "个人信息",
"description": "基本个人资料。",
"fullName": "姓名",
"gender": "性别",
"age": "年龄",
"phone": "电话",
"address": "地址"
},
"accountInfo": {
"title": "账户信息",
"description": "系统账户详情。",
"email": "邮箱",
"role": "角色",
"memberSince": "注册时间",
"onboardedAt": "入职时间"
}
},
"studentOverview": {
"title": "学生概览",
"description": "您的学业表现和课表。"
},
"teacherOverview": {
"title": "教师概览",
"description": "您任教的科目和班级。",
"teachingSubjects": "任教科目",
"teachingSubjectsDesc": "您当前被分配教授的科目。",
"noSubjects": "暂无分配科目。",
"teachingClasses": "任教班级",
"teachingClassesDesc": "您当前管理的班级。",
"noClasses": "暂无分配班级。",
"view": "查看"
}
},
"notifications": {
"title": "通知偏好",
@@ -211,11 +248,14 @@
"baseUrl": "API 地址",
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
"baseUrlDesc": "输入基础地址,无需 /chat/completions 后缀。",
"baseUrlDescOllama": "Ollama 本地地址,默认 http://localhost:11434/v1留空使用默认值。",
"model": "模型",
"modelPlaceholder": "gpt-4o-mini",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "粘贴新密钥以替换",
"apiKeyDesc": "已有密钥不会显示。留空则保留当前密钥。",
"apiKeyPlaceholderOllama": "本地部署无需密钥,留空即可",
"apiKeyDescOllama": "Ollama 本地部署无需 API 密钥。如已配置 OLLAMA_API_KEY 可填入。",
"setDefault": "设为默认",
"visibility": "可见性",
"visibilityDesc": "公开 Provider 由管理员发布,全员可用;私有 Provider 仅创建者可见。",
@@ -275,6 +315,19 @@
"sectionLoadFailed": "该区块加载失败",
"sectionLoadFailedDesc": "请稍后重试。"
},
"brand": {
"title": "品牌配置",
"description": "学校品牌信息,将显示在登录/注册页面。",
"schoolName": "品牌名称",
"logoUrl": "Logo URL",
"logoUrlDescription": "可选,留空将使用默认图标。建议使用方形 PNG/SVG。",
"testimonialQuote": "宣传标语",
"testimonialAuthor": "标语作者",
"save": "保存品牌配置",
"saveSuccess": "品牌配置已保存",
"saveFailed": "保存品牌配置失败",
"loadFailed": "加载品牌配置失败"
},
"admin": {
"title": "系统设置",
"description": "管理系统基础信息与运行参数。",
@@ -329,42 +382,5 @@
"saveSuccess": "设置已保存",
"saveFailure": "设置保存失败",
"loadFailure": "加载系统设置失败"
},
"profilePage": {
"title": "个人资料",
"description": "管理您的个人和账户信息。",
"editProfile": "编辑资料",
"personalInfo": {
"title": "个人信息",
"description": "基本个人资料。",
"fullName": "姓名",
"gender": "性别",
"age": "年龄",
"phone": "电话",
"address": "地址"
},
"accountInfo": {
"title": "账户信息",
"description": "系统账户详情。",
"email": "邮箱",
"role": "角色",
"memberSince": "注册时间",
"onboardedAt": "入职时间"
},
"studentOverview": {
"title": "学生概览",
"description": "您的学业表现和课表。"
},
"teacherOverview": {
"title": "教师概览",
"description": "您任教的科目和班级。",
"teachingSubjects": "任教科目",
"teachingSubjectsDesc": "您当前被分配教授的科目。",
"noSubjects": "暂无分配科目。",
"teachingClasses": "任教班级",
"teachingClassesDesc": "您当前管理的班级。",
"noClasses": "暂无分配班级。",
"view": "查看"
}
}
}

View File

@@ -66,6 +66,12 @@
"title": "我的学情诊断",
"description": "查看你的知识点掌握度分析与诊断报告。"
},
"studyPath": {
"title": "AI 学习路径",
"description": "基于你的学情数据AI 为你生成个性化学习路径,按步骤攻克薄弱知识点。",
"noUser": "未找到用户",
"noUserDesc": "请先登录学生账号以生成学习路径。"
},
"error": {
"title": "出错了",
"description": "发生了意外错误,请稍后重试。",

View File

@@ -164,6 +164,10 @@
"grade": {
"grade1": "一年级",
"grade2": "二年级",
"grade3": "三年级",
"grade4": "四年级",
"grade5": "五年级",
"grade6": "六年级",
"grade7": "七年级",
"grade8": "八年级",
"grade9": "九年级",
@@ -215,7 +219,9 @@
"prerequisiteCreated": "前置依赖已添加",
"prerequisiteCreateFailed": "添加前置依赖失败",
"prerequisiteDeleted": "前置依赖已删除",
"prerequisiteDeleteFailed": "删除前置依赖失败"
"prerequisiteDeleteFailed": "删除前置依赖失败",
"notFound": "教材不存在或无权访问",
"noClassMasteryPermission": "无权查看班级掌握度数据"
},
"graph": {
"viewMode": {
@@ -231,6 +237,7 @@
},
"detail": {
"title": "知识点详情",
"close": "关闭",
"noDescription": "暂无描述",
"viewAllQuestions": "查看全部题目",
"editPrerequisite": "编辑前置依赖",
@@ -246,6 +253,8 @@
"totalQuestions": "总题数",
"prerequisiteAdded": "前置依赖已添加",
"prerequisiteRemoved": "前置依赖已移除",
"prerequisiteAddFailed": "添加前置依赖失败",
"prerequisiteRemoveFailed": "删除前置依赖失败",
"cancel": "取消",
"confirm": "确认",
"saving": "保存中..."

View File

@@ -1,6 +1,43 @@
{
"title": "用户管理",
"description": "管理系统所有用户。",
"adminDescription": "管理所有系统用户,包括查看、搜索、删除。",
"importButton": "批量导入",
"searchPlaceholder": "搜索姓名或邮箱...",
"allRoles": "所有角色",
"search": "搜索",
"reset": "重置",
"emptyTitle": "暂无用户",
"emptyFilteredDescription": "没有匹配的用户,请调整搜索条件。",
"emptyDescription": "系统中还没有用户,点击批量导入创建。",
"unassigned": "未分配",
"deleteConfirmTitle": "确认删除用户?",
"deleteConfirmDescription": "此操作将永久删除该用户及其关联数据,且不可恢复。如果该用户关联了班级或学生,请先解除关联。",
"columns": {
"name": "姓名",
"email": "邮箱",
"role": "角色",
"phone": "手机",
"createdAt": "注册时间",
"actions": "操作"
},
"actions": {
"edit": "编辑",
"assignRoles": "分配角色",
"delete": "删除",
"cancel": "取消",
"deleting": "删除中...",
"confirmDelete": "确认删除"
},
"pagination": {
"info": "显示第 {start}-{end} 条,共 {total} 条",
"prev": "上一页",
"pageInfo": "第 {page} / {totalPages} 页",
"next": "下一页"
},
"messages": {
"deleted": "用户已删除"
},
"import": {
"title": "批量导入用户",
"description": "通过 Excel 文件批量创建用户账号,支持学生自动加入班级。",
@@ -41,4 +78,4 @@
"fieldInviteCodeRequired": "选填",
"fieldInviteCodeDescription": "仅 student 角色有效6 位邀请码"
}
}
}

View File

@@ -36,10 +36,13 @@ export class NotFoundError extends BusinessError {
/**
* 输入校验错误。消息可安全返回客户端。
*
* `code` 可选,默认 `"validation_error"`。当需要前端按字段精确本地化时
* 可传入自定义错误码(如 `invalid_date:publishDate`),前端通过 errorCode 查 i18n。
*/
export class ValidationError extends BusinessError {
constructor(message: string) {
super(message, "validation_error")
constructor(message: string, code?: string) {
super(message, code ?? "validation_error")
this.name = "ValidationError"
}
}
@@ -59,19 +62,19 @@ export function handleActionError(e: unknown): ActionState<never> {
return { success: false, message: e.message }
}
// 业务错误:消息可安全暴露
// 业务错误:消息可安全暴露;保留 code → errorCode 供前端 i18n 查找
if (e instanceof BusinessError) {
return { success: false, message: e.message }
return { success: false, message: e.message, errorCode: e.code }
}
// 未知错误:不暴露内部细节,仅记录服务端日志
if (e instanceof Error) {
console.error("[ActionError]", e.name, e.message, e.stack)
return { success: false, message: "操作失败,请稍后重试" }
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
console.error("[ActionError] Unknown error:", e)
return { success: false, message: "操作失败,请稍后重试" }
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
/**
@@ -135,6 +138,9 @@ export function safeJsonParse<T>(json: string, errorMessage: string): T {
/**
* 校验日期字符串是否有效,无效则抛出 ValidationError。
*
* `fieldName` 用于构造错误码 `invalid_date:{fieldName}`,前端通过 errorCode 查 i18n。
* message 保留中文兜底,前端应优先使用 errorCode 本地化。
*
* @returns 解析后的 Date 对象
*/
export function safeParseDate(value: string, fieldName: string): Date {

View File

@@ -5,9 +5,25 @@ import OpenAI from "openai"
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
import { getAiProviderConfig } from "./provider-config"
/** AI 请求超时(毫秒) */
/** AI 请求超时(毫秒)— 用于普通非流式调用 */
const AI_TIMEOUT_MS = 30000
/**
* 测试连接超时(毫秒)
*
* 测试连接需要实际调用 AI 模型生成响应,部分 Provider如本地 Ollama
* 首次加载模型可能耗时 30s 以上,因此使用更长的超时时间。
*/
const AI_TEST_TIMEOUT_MS = 120000
/**
* 流式响应超时(毫秒)
*
* 流式生成内容(特别是长文本)可能需要较长时间,
* 此超时控制整个流式请求的总时长。
*/
const AI_STREAM_TIMEOUT_MS = 180000
/** 可重试的 HTTP 状态码429 限流 + 5xx 服务端错误) */
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
@@ -17,12 +33,19 @@ const MAX_RETRIES = 2
/** 基础重试延迟(毫秒),实际延迟 = base * 2^attempt */
const RETRY_BASE_DELAY_MS = 1000
const getAiClient = async (config: { apiKey: string; baseUrl?: string }): Promise<OpenAI> => {
interface AiClientOptions {
apiKey: string
baseUrl?: string
/** 请求超时(毫秒),默认 AI_TIMEOUT_MS */
timeout?: number
}
const getAiClient = async (config: AiClientOptions): Promise<OpenAI> => {
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({
apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined,
timeout: AI_TIMEOUT_MS,
timeout: config.timeout ?? AI_TIMEOUT_MS,
maxRetries: 0, // 由业务层控制重试
})
}
@@ -67,7 +90,11 @@ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }): Promise<boolean> => {
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const client = await getAiClient({
apiKey: input.apiKey,
baseUrl: input.baseUrl,
timeout: AI_TEST_TIMEOUT_MS,
})
const result = await client.chat.completions.create({
model: input.model,
messages: [{ role: "user", content: "ping" }],
@@ -84,7 +111,11 @@ export const testAiProviderById = async (
overrides?: { baseUrl?: string; model?: string }
): Promise<boolean> => {
const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const client = await getAiClient({
apiKey: config.apiKey,
baseUrl: overrides?.baseUrl ?? config.baseUrl,
timeout: AI_TEST_TIMEOUT_MS,
})
const result = await client.chat.completions.create({
model: overrides?.model ?? config.model,
messages: [{ role: "user", content: "ping" }],
@@ -127,13 +158,17 @@ export const createAiChatCompletion = async (input: AiChatRequest): Promise<{ co
* 用于 SSE 流式响应,降低用户感知延迟。
*
* 注意:流式调用不使用 withRetry因为流一旦开始无法重试。
* 超时由 OpenAI SDK 的 timeout 配置控制。
* 超时由 OpenAI SDK 的 timeout 配置控制(使用 AI_STREAM_TIMEOUT_MS
*/
export async function* createAiChatCompletionStream(
input: AiChatRequest
): AsyncGenerator<string, void, unknown> {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const client = await getAiClient({
apiKey: config.apiKey,
baseUrl: config.baseUrl,
timeout: AI_STREAM_TIMEOUT_MS,
})
const stream = await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,

View File

@@ -13,12 +13,15 @@ export type AiProviderConfig = {
apiKey: string
baseUrl?: string
model: string
/** Provider 类型标识(用于客户端特殊处理,如 Ollama 本地部署) */
provider?: string
}
type ProviderAccessRow = {
apiKeyEncrypted: string
baseUrl: string | null
model: string
provider: string
visibility: "public" | "private"
createdBy: string | null
}
@@ -74,11 +77,23 @@ function buildVisibilityFilter(userCtx: UserContext | null): SQL | undefined {
) ?? undefined
}
/** Ollama 默认 baseUrlOpenAI 兼容端点) */
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
/** Ollama 占位符 API Key本地部署无需真实密钥OpenAI SDK 需要非空值) */
const OLLAMA_PLACEHOLDER_API_KEY = "ollama"
function toConfig(row: ProviderAccessRow): AiProviderConfig {
const isOllama = row.provider === "ollama"
const apiKey = isOllama
? OLLAMA_PLACEHOLDER_API_KEY
: decryptAiApiKey(row.apiKeyEncrypted)
const baseUrl = row.baseUrl ?? (isOllama ? OLLAMA_DEFAULT_BASE_URL : undefined)
return {
apiKey: decryptAiApiKey(row.apiKeyEncrypted),
baseUrl: row.baseUrl ?? undefined,
apiKey,
baseUrl,
model: row.model,
provider: row.provider,
}
}
@@ -86,6 +101,7 @@ const selectColumns = {
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
provider: aiProviders.provider,
visibility: aiProviders.visibility,
createdBy: aiProviders.createdBy,
} as const

View File

@@ -1,19 +1,30 @@
import type { Permission, DataScope, AuthContext, Role } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, inArray, or } from "drizzle-orm"
import { isPermission } from "@/shared/lib/type-guards"
import { getSession } from "@/shared/lib/session"
import { PermissionDeniedError } from "@/shared/lib/errors"
// Re-export for backward compatibility (other modules still import from here)
export { PermissionDeniedError } from "@/shared/lib/errors"
/**
* Resolve the data scope for a user based on their roles.
*
* Delegates to the RBAC module's configuration-driven resolver via dynamic
* import, so the shared layer never statically depends on modules/*.
* This is the same pattern used by shared/lib/session.ts to break the
* shared ↔ auth circular dependency.
*
* P1-5/P1-6 audit fix: removed direct DB queries against classes,
* classEnrollments, classSubjectTeachers, grades, parentStudentRelations
* tables. The resolver now calls module data-access functions and is
* driven by a configuration array (DATA_SCOPE_RULES) instead of hardcoded
* role-name checks.
*/
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
const { resolveDataScopeFromConfig } = await import("@/modules/rbac/lib/data-scope-resolver")
return resolveDataScopeFromConfig(userId, roleNames)
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
@@ -24,8 +35,11 @@ export async function getAuthContext(): Promise<AuthContext> {
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as Role[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Use type guards to safely coerce session values
const roleNames = (session.user.roles ?? []).filter(
(r): r is Role => typeof r === "string"
)
const permissions = (session.user.permissions ?? []).filter(isPermission)
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
@@ -55,118 +69,6 @@ export async function checkPermission(
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* Resolve the data scope for a user based on their roles.
* Queries the DB for resource ownership information.
*/
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
// Admin sees everything
if (roleNames.includes("admin")) {
return { type: "all" }
}
// Grade head / teaching head: can manage their grades
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
}
}
// Teacher: can see their own classes
if (roleNames.includes("teacher")) {
// Classes where user is the homeroom teacher
const homeroomClasses = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, userId))
// Classes where user is a subject teacher
const subjectClasses = await db
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, userId))
const classIds = [
...new Set([
...homeroomClasses.map((c) => c.id),
...subjectClasses.map((c) => c.classId),
]),
]
const subjectIds = subjectClasses
.map((c) => c.subjectId)
.filter((s): s is string => s !== null)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
}
// Student: can see data from their enrolled classes
// Pre-resolve classIds and gradeIds here to avoid N+1 queries in data-access layer
if (roleNames.includes("student")) {
const enrolledClasses = await db
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(eq(classEnrollments.studentId, userId))
const gradeIds = [
...new Set(
enrolledClasses
.map((c) => c.gradeId)
.filter((g): g is string => g !== null),
),
]
return {
type: "class_members",
classIds: enrolledClasses.map((c) => c.classId),
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
}
}
// Parent: can see their children's data
if (roleNames.includes("parent")) {
const children = await db
.select({ studentId: parentStudentRelations.studentId })
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
const childrenIds = children.map((c) => c.studentId)
// Pre-resolve gradeIds from children's enrolled classes
let gradeIds: string[] | undefined
if (childrenIds.length > 0) {
const childrenClasses = await db
.select({ gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.studentId, childrenIds))
const uniqueGradeIds = [
...new Set(
childrenClasses
.map((c) => c.gradeId)
.filter((g): g is string => g !== null),
),
]
gradeIds = uniqueGradeIds.length > 0 ? uniqueGradeIds : undefined
}
return { type: "children", childrenIds, gradeIds }
}
// Fallback: only own data
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.

View File

@@ -0,0 +1,125 @@
import "server-only"
/**
* Breached password detection via Have I Been Pwned API (audit-P2-4).
*
* 使用 k-anonymity 范围查询:仅发送 SHA-1 哈希前 5 个字符到 HIBP API
* API 返回该前缀下所有泄露密码的哈希后缀 + 出现次数。本地比对完整哈希后缀,
* 从而在不泄露用户密码的前提下判断是否为已泄露密码。
*
* 安全设计:
* - 仅发送 5 字符前缀HIBP 无法得知用户查询的完整密码哈希
* - 本地完成完整哈希比对,不发送完整哈希
* - API 失败时 fail-open允许密码避免外部依赖阻断注册流程
* - 带 3 秒超时 + 1 次重试,避免长时间阻塞
*
* 集成点:
* - `modules/auth/actions.ts` registerAction注册时校验
* - `modules/settings/actions-password.ts` changePasswordAction改密时校验
*/
const HIBP_API_URL = "https://api.pwnedpasswords.com/range"
const REQUEST_TIMEOUT_MS = 3000
export interface BreachedPasswordResult {
/** true 表示此密码已在已知数据泄露中出现,不应使用 */
isBreached: boolean
/** 该密码在 HIBP 数据库中被泄露的次数isBreached=false 时为 0 */
breachCount: number
/** true 表示因 API 不可用等原因无法完成检查fail-open允许通过 */
checkSkipped: boolean
/** checkSkipped=true 时的原因 */
reason?: string
}
/**
* 计算 SHA-1 哈希hex 格式,全大写)。
* 使用 Web Crypto APINode.js / Edge runtime 均可用)。
*/
async function sha1Hex(input: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(input)
const hashBuffer = await crypto.subtle.digest("SHA-1", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase()
}
/**
* 带 timeout 的 fetchAbortController
*/
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "NextEdu-PasswordCheck/1.0" },
})
} finally {
clearTimeout(timer)
}
}
/**
* 查询 HIBP API 获取指定前缀下的所有泄露哈希后缀。
* 返回 Map<suffix, count>。
*/
async function queryHibpRange(prefix: string): Promise<Map<string, number>> {
const response = await fetchWithTimeout(`${HIBP_API_URL}/${prefix}`, REQUEST_TIMEOUT_MS)
if (!response.ok) {
throw new Error(`HIBP API returned ${response.status}`)
}
const text = await response.text()
const result = new Map<string, number>()
for (const line of text.split("\n")) {
const trimmed = line.trim()
if (!trimmed) continue
const colonIdx = trimmed.indexOf(":")
if (colonIdx === -1) continue
const suffix = trimmed.slice(0, colonIdx)
const count = parseInt(trimmed.slice(colonIdx + 1), 10)
if (!Number.isNaN(count)) {
result.set(suffix, count)
}
}
return result
}
/**
* 检查密码是否在已知数据泄露中出现。
*
* k-anonymity 流程:
* 1. 计算 SHA-1 哈希
* 2. 取前 5 字符作为前缀查询 HIBP API
* 3. API 返回该前缀下所有泄露后缀 + 次数
* 4. 本地比对完整哈希的后缀(第 6 字符起)
*
* 失败策略fail-open
* - API 不可用 / 超时 / 返回错误 → 返回 checkSkipped=true允许密码通过
* - 不应因第三方服务故障阻断用户注册/改密
*/
export async function checkBreachedPassword(
password: string,
): Promise<BreachedPasswordResult> {
const fullHash = await sha1Hex(password)
const prefix = fullHash.slice(0, 5)
const suffix = fullHash.slice(5)
try {
const rangeMap = await queryHibpRange(prefix)
const count = rangeMap.get(suffix) ?? 0
return {
isBreached: count > 0,
breachCount: count,
checkSkipped: false,
}
} catch (error) {
const reason = error instanceof Error ? error.message : "unknown error"
return {
isBreached: false,
breachCount: 0,
checkSkipped: true,
reason: `HIBP API unavailable: ${reason}`,
}
}
}

View File

@@ -0,0 +1,80 @@
/**
* 通用 CSV 导出工具(共享层)。
*
* 纯函数实现,便于单测。支持 CSV 格式导出,兼容 Excel含 UTF-8 BOM
* PDF 导出需要客户端库(如 jsPDF作为后续扩展点。
*/
/** 导出数据行类型 */
export interface ExportRow {
[key: string]: string | number | boolean | null
}
/** 导出列配置 */
export interface ExportColumn {
/** 数据字段名 */
key: string
/** 导出列标题 */
label: string
}
/**
* 将数据行数组转换为 CSV 字符串。
*
* - 添加 UTF-8 BOM 以支持 Excel 正确显示中文
* - 字段值包含逗号、换行、引号时自动转义
* - null/undefined 转为空字符串
*/
export function toCSV(rows: readonly ExportRow[], columns: readonly ExportColumn[]): string {
const BOM = "\uFEFF"
const escapeCell = (value: string | number | boolean | null): string => {
if (value === null || value === undefined) return ""
const str = String(value)
if (str.includes(",") || str.includes("\n") || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
const header = columns.map((c) => escapeCell(c.label)).join(",")
const body = rows
.map((row) => columns.map((c) => escapeCell(row[c.key] ?? null)).join(","))
.join("\n")
return `${BOM}${header}\n${body}`
}
/**
* 触发浏览器下载文件(客户端调用)。
*
* 创建临时 Blob URL 并模拟点击下载,
* 下载完成后自动清理 URL。
*/
export function downloadFile(content: string, filename: string, mimeType = "text/csv;charset=utf-8"): void {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 导出数据为 CSV便捷方法
*
* @param rows 数据行
* @param columns 列配置
* @param filename 文件名(不含扩展名)
*/
export function exportCSV(
rows: readonly ExportRow[],
columns: readonly ExportColumn[],
filename: string,
): void {
const csv = toCSV(rows, columns)
downloadFile(csv, `${filename}.csv`)
}

View File

@@ -0,0 +1,226 @@
/**
* 权限位图编解码audit-P1-7
*
* 目的:将 67 个权限点字符串数组(约 1.1KB JSON压缩为 ~14 字符的 base36 字符串,
* 使 JWT cookie 体积降至可接受范围(远低于 4KB 限制)。
*
* 编码方案:
* - 权限点按 `PERMISSION_BITMAP_ORDER` 数组中的位置分配 bit0-66
* - 67 bit 打包为 BigInt转为 base36 字符串
* - base36 字符集 [0-9a-z]URL 安全,无需 padding
*
* 设计约束:
* - `PERMISSION_BITMAP_ORDER` 必须与 `Permissions` 枚举值一一对应
* - 顺序固定,新增权限只能追加到数组末尾(不破坏已有 bit 位映射)
* - 编解码为纯函数,可在 edge runtime 使用proxy.ts 直接调用)
*
* 注意:`BigInt` 不支持按 radix 解析字符串,需手工按位累加 base36 → BigInt。
* 切勿用 `parseInt(bitmap, 36)` —— `Number` 仅支持 53 bit 精度,
* 67 bit 的位图会丢失精度。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
const BASE36 = 36n
const BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
/**
* 将 base36 字符串解析为 BigInt。
* 输入仅接受 `[0-9a-z]`(大小写不敏感),无效字符抛出异常。
*/
function parseBase36ToBigInt(input: string): bigint {
let result = 0n
for (const ch of input.toLowerCase()) {
const digit = BASE36_DIGITS.indexOf(ch)
if (digit === -1) {
throw new Error(`Invalid base36 character: ${ch}`)
}
result = result * BASE36 + BigInt(digit)
}
return result
}
/**
* 权限位图的稳定顺序。
*
* **重要**:此数组的顺序一经确定即不可更改,新增权限只能追加到末尾。
* 顺序变更会导致已签发的 JWT 中权限位错位。
*
* 当前包含 67 个权限点,覆盖 `Permissions` 枚举的全部值。
*/
export const PERMISSION_BITMAP_ORDER: readonly Permission[] = [
// Exam
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_SUBMIT,
// Homework
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.HOMEWORK_SUBMIT,
// Question
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
// Textbook
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.TEXTBOOK_DELETE,
// Class
Permissions.CLASS_CREATE,
Permissions.CLASS_READ,
Permissions.CLASS_UPDATE,
Permissions.CLASS_DELETE,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
// School management
Permissions.SCHOOL_MANAGE,
Permissions.GRADE_MANAGE,
Permissions.USER_MANAGE,
Permissions.USER_PROFILE_UPDATE,
// AI
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
// Settings
Permissions.SETTINGS_ADMIN,
// Audit
Permissions.AUDIT_LOG_READ,
// Announcement
Permissions.ANNOUNCEMENT_MANAGE,
Permissions.ANNOUNCEMENT_READ,
// Grade Record
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
// File
Permissions.FILE_UPLOAD,
Permissions.FILE_READ,
Permissions.FILE_DELETE,
// Course Plan
Permissions.COURSE_PLAN_MANAGE,
Permissions.COURSE_PLAN_READ,
// Attendance
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
// Leave Request
Permissions.LEAVE_REQUEST_CREATE,
Permissions.LEAVE_REQUEST_READ,
Permissions.LEAVE_REQUEST_REVIEW,
// Message
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
// Scheduling
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
// Elective
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.ELECTIVE_SELECT,
// Exam Proctoring
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
// Diagnostic
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
// Lesson Plan
Permissions.LESSON_PLAN_CREATE,
Permissions.LESSON_PLAN_READ,
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
// Dashboard
Permissions.DASHBOARD_ADMIN_READ,
Permissions.DASHBOARD_TEACHER_READ,
Permissions.DASHBOARD_STUDENT_READ,
Permissions.DASHBOARD_PARENT_READ,
// Error Book
Permissions.ERROR_BOOK_READ,
Permissions.ERROR_BOOK_MANAGE,
Permissions.ERROR_BOOK_ANALYTICS_READ,
// Adaptive Practice
Permissions.ADAPTIVE_PRACTICE_READ,
Permissions.ADAPTIVE_PRACTICE_MANAGE,
// RBAC
Permissions.ROLE_CREATE,
Permissions.ROLE_READ,
Permissions.ROLE_UPDATE,
Permissions.ROLE_DELETE,
Permissions.ROLE_ASSIGN,
Permissions.PERMISSION_READ,
] as const
/** 权限 → bit 位的映射表(懒构造,进程级复用) */
const permissionToBit = new Map<Permission, number>(
PERMISSION_BITMAP_ORDER.map((p, i) => [p, i]),
)
/**
* 将权限数组编码为 base36 位图字符串。
*
* 例如 admin 的 67 个权限编码后约为 14 字符的字符串,
* 相比 JSON 数组的 ~1.1KB 体积减少约 99%。
*
* 未知权限(不在 `PERMISSION_BITMAP_ORDER` 中)会被静默忽略,
* 避免新增权限但位图未更新时抛错。
*/
export function encodePermissionsBitmap(permissions: Permission[]): string {
let bits = 0n
for (const p of permissions) {
const bit = permissionToBit.get(p)
if (bit !== undefined) {
bits |= 1n << BigInt(bit)
}
}
if (bits === 0n) return "0"
return bits.toString(36)
}
/**
* 将 base36 位图字符串解码为权限数组。
*
* 无效或无法识别的字符返回空数组(容错处理)。
*/
export function decodePermissionsBitmap(bitmap: string): Permission[] {
if (!bitmap || bitmap === "0") return []
try {
const bits = parseBase36ToBigInt(bitmap)
if (bits === 0n) return []
const result: Permission[] = []
for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) {
if ((bits & (1n << BigInt(i))) !== 0n) {
result.push(PERMISSION_BITMAP_ORDER[i])
}
}
return result
} catch {
return []
}
}
/**
* 检查位图中是否包含指定权限,无需完整解码。
*
* 用于 proxy.ts 等对性能敏感的场景,避免每次路由检查都解码全部权限。
*/
export function hasPermissionInBitmap(
bitmap: string,
permission: Permission,
): boolean {
const bit = permissionToBit.get(permission)
if (bit === undefined || !bitmap || bitmap === "0") return false
try {
const bits = parseBase36ToBigInt(bitmap)
return (bits & (1n << BigInt(bit))) !== 0n
} catch {
return false
}
}

View File

@@ -1,8 +1,21 @@
import { Permissions, type Permission, type Role } from "@/shared/types/permissions"
import { Permissions, type Permission, type BuiltinRole } from "@/shared/types/permissions"
import { isPermission } from "@/shared/lib/type-guards"
import { db } from "@/shared/db"
import { roles, rolePermissions } from "@/shared/db/schema"
import { and, eq, inArray } from "drizzle-orm"
// Role → Permission mapping
// New roles only need to add an entry here + seed the DB
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
/**
* Seed role → permission mapping for the 6 builtin roles.
*
* Used by:
* - The seed migration to populate the `role_permissions` table.
* - `resolvePermissions()` as a fallback when the DB query fails (e.g. during
* initial bootstrap before the migration has run).
*
* Runtime permission resolution reads from the DB — this constant is NOT
* consulted at runtime except as a fallback.
*/
export const ROLE_PERMISSIONS_SEED: Record<BuiltinRole, Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
@@ -59,12 +72,22 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
Permissions.STANDARD_READ,
Permissions.STANDARD_MANAGE,
Permissions.STANDARD_LINK,
Permissions.FILE_UPLOAD,
Permissions.FILE_READ,
Permissions.FILE_DELETE,
Permissions.DASHBOARD_ADMIN_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
Permissions.ADAPTIVE_PRACTICE_READ,
// RBAC management — admin only by default
Permissions.ROLE_CREATE,
Permissions.ROLE_READ,
Permissions.ROLE_UPDATE,
Permissions.ROLE_DELETE,
Permissions.ROLE_ASSIGN,
Permissions.PERMISSION_READ,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -108,6 +131,8 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
Permissions.STANDARD_READ,
Permissions.STANDARD_LINK,
Permissions.DASHBOARD_TEACHER_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
Permissions.ADAPTIVE_PRACTICE_READ,
@@ -143,6 +168,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.ATTENDANCE_READ,
@@ -232,13 +258,53 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
}
/**
* Merge permissions from all roles (deduplicated)
* @deprecated Use `ROLE_PERMISSIONS_SEED` instead. Kept as a re-export for
* backward compatibility with any code that still imports `ROLE_PERMISSIONS`.
*/
export function resolvePermissions(roleNames: Role[]): Permission[] {
const set = new Set<Permission>()
for (const name of roleNames) {
const perms = ROLE_PERMISSIONS[name] ?? []
for (const p of perms) set.add(p)
export const ROLE_PERMISSIONS = ROLE_PERMISSIONS_SEED
/**
* Merge permissions from all roles by querying the `role_permissions` table.
*
* - Only enabled roles contribute permissions (`roles.is_enabled = true`).
* - Falls back to `ROLE_PERMISSIONS_SEED` for builtin roles if the DB query
* fails (e.g. during initial bootstrap before the migration has run).
* - Deduplicates the resulting permission list.
*/
export async function resolvePermissions(roleNames: string[]): Promise<Permission[]> {
if (roleNames.length === 0) return []
try {
const rows = await db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.innerJoin(roles, eq(rolePermissions.roleId, roles.id))
.where(and(inArray(roles.name, roleNames), eq(roles.isEnabled, true)))
const set = new Set<Permission>()
for (const row of rows) {
if (isPermission(row.permission)) {
set.add(row.permission)
}
}
// Fallback: if the DB returned nothing for builtin roles (e.g. migration
// not yet applied), use the seed constant so login still works.
if (set.size === 0) {
for (const name of roleNames) {
const seed = ROLE_PERMISSIONS_SEED[name as BuiltinRole]
if (seed) for (const p of seed) set.add(p)
}
}
return Array.from(set)
} catch {
// DB unavailable (e.g. during build) — fall back to seed for builtin roles
const set = new Set<Permission>()
for (const name of roleNames) {
const seed = ROLE_PERMISSIONS_SEED[name as BuiltinRole]
if (seed) for (const p of seed) set.add(p)
}
return Array.from(set)
}
return Array.from(set)
}

View File

@@ -22,6 +22,51 @@ export const getQuestionText = (content: unknown): string => {
return typeof content.text === "string" ? content.text : ""
}
/**
* 从题目内容中递归提取纯文本预览。
*
* 支持以下内容格式:
* - 字符串:直接返回(可截断)
* - 数组:递归提取每个节点的 text 和 children拼接为纯文本
* - 对象:提取 text 属性
* - 其他:返回 fallback
*
* @param content 题目内容unknown 类型)
* @param fallback 无法提取时的回退文本,默认空字符串
* @param maxLength 最大长度0 表示不截断,默认 0
* @returns 提取的纯文本
*/
export function extractQuestionPreview(
content: unknown,
fallback = "",
maxLength = 0,
): string {
const text = extractTextRecursive(content)
if (text) {
return maxLength > 0 ? text.slice(0, maxLength) : text
}
return fallback
}
function extractTextRecursive(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
const parts: string[] = []
for (const node of content) {
const text = extractTextRecursive(node)
if (text) parts.push(text)
}
return parts.join("")
}
if (isRecord(content)) {
if (typeof content.text === "string") return content.text
if (Array.isArray(content.children)) {
return extractTextRecursive(content.children)
}
}
return ""
}
export const getOptions = (content: unknown): QuestionOption[] => {
if (!isRecord(content)) return []
const raw = content.options

View File

@@ -1,119 +0,0 @@
/**
* In-memory rate limiter (single-instance only).
*
* For multi-instance deployments, replace with @upstash/ratelimit +
* @upstash/redis. The API below mirrors the upstash `Ratelimiter.limit`
* shape so the swap is straightforward.
*
* Entries are pruned lazily on each call to keep the Map bounded.
*/
interface RateLimitEntry {
count: number
resetTime: number
}
const rateLimitMap = new Map<string, RateLimitEntry>()
/** Prune entries whose window has elapsed. Called on every limit check. */
function pruneExpired(now: number) {
if (rateLimitMap.size === 0) return
for (const [key, entry] of rateLimitMap.entries()) {
if (entry.resetTime <= now) {
rateLimitMap.delete(key)
}
}
}
export interface RateLimitResult {
success: boolean
remaining: number
resetTime: number
/** Milliseconds until the window resets (0 when already reset). */
retryAfterMs: number
}
export interface RateLimitParams {
/** Unique identifier for the bucket (e.g. `login:${ip}` or `ai:${userId}`). */
key: string
/** Maximum number of requests allowed within the window. */
limit: number
/** Window size in milliseconds. */
windowMs: number
}
/**
* Check whether a request should be allowed under the given rate limit.
* Increments the counter regardless of success (so repeated failures
* accumulate).
*/
export function rateLimit(params: RateLimitParams): RateLimitResult {
const now = Date.now()
pruneExpired(now)
const existing = rateLimitMap.get(params.key)
if (!existing || existing.resetTime <= now) {
// Start a fresh window
const resetTime = now + params.windowMs
rateLimitMap.set(params.key, { count: 1, resetTime })
return {
success: true,
remaining: params.limit - 1,
resetTime,
retryAfterMs: 0,
}
}
// Within an existing window
existing.count += 1
const remaining = Math.max(0, params.limit - existing.count)
const success = existing.count <= params.limit
const retryAfterMs = success ? 0 : existing.resetTime - now
return {
success,
remaining,
resetTime: existing.resetTime,
retryAfterMs,
}
}
/**
* Reset the counter for a key. Useful when a successful action should
* clear the failure count (e.g. successful login clears LOGIN limit).
*/
export function resetRateLimit(key: string): void {
rateLimitMap.delete(key)
}
/**
* Predefined rate limit rules for common scenarios.
* Times are in milliseconds.
*/
export const RATE_LIMIT_RULES = {
LOGIN: { limit: 5, windowMs: 15 * 60 * 1000 }, // 5 attempts per 15 minutes
API: { limit: 100, windowMs: 60 * 1000 }, // 100 requests per minute
UPLOAD: { limit: 10, windowMs: 60 * 1000 }, // 10 uploads per minute
AI_CHAT: { limit: 20, windowMs: 60 * 1000 }, // 20 chats per minute
PASSWORD_CHANGE: { limit: 5, windowMs: 60 * 1000 }, // 5 attempts per minute
} as const
/**
* Build a rate-limit key from a prefix and identifier.
*/
export function rateLimitKey(prefix: string, identifier: string): string {
return `${prefix}:${identifier}`
}
/**
* Convert a RateLimitResult into standard HTTP response headers.
*/
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
"X-RateLimit-Limit": String(result.remaining + (result.success ? 1 : 0)),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetTime / 1000)),
...(result.success ? {} : { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}

View File

@@ -0,0 +1,71 @@
/**
* 速率限制器门面audit-P1-6
*
* 公共 API 与原 `rate-limit.ts` 单文件保持一致,差异:
* - `rateLimit()` 与 `resetRateLimit()` 改为 `Promise` 返回,以支持异步后端
* - 内部根据 `RATE_LIMIT_DRIVER` 环境变量选择实现:
* - `memory`(默认):单实例内存滑动窗口
* - `redis`:基于 @upstash/ratelimit + @upstash/redis 的分布式实现
* - 调用方需 `await rateLimit(...)` / `await resetRateLimit(...)`
*
* `rateLimitKey` / `RATE_LIMIT_RULES` / `rateLimitHeaders` 保持同步纯函数。
*/
import { env } from "@/env.mjs"
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
export type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
export { RATE_LIMIT_RULES, rateLimitHeaders, rateLimitKey } from "./rules"
import { MemoryRateLimiter } from "./memory-limiter"
import { RedisRateLimiter } from "./redis-limiter"
/**
* 单例限流器实例(按 env 选择实现,进程级复用)。
*
* RedisRateLimiter 类本身在 import 时不会加载 @upstash/* 依赖——
* 重依赖通过类方法内的 `await import(...)` 懒加载,
* 仅当 `RATE_LIMIT_DRIVER=redis` 且实际调用 `limit()` 时才加载。
*/
let singleton: RateLimiter | null = null
/**
* 获取当前进程的限流器实例。
*
* - 默认返回内存实现
* - 当 `RATE_LIMIT_DRIVER=redis` 时返回 Redis 实现
* - Redis 实现懒加载所需依赖,未安装 @upstash/ratelimit/redis 时首次调用抛错
*/
export function getRateLimiter(): RateLimiter {
if (singleton) return singleton
if (env.RATE_LIMIT_DRIVER === "redis") {
singleton = new RedisRateLimiter()
} else {
singleton = new MemoryRateLimiter()
}
return singleton
}
/**
* 检查请求是否应被允许。
*
* 无论成功失败均累加计数(便于失败次数累计锁定)。
*
* **变更**:返回 `Promise<RateLimitResult>`,调用方需 `await`。
*/
export function rateLimit(params: RateLimitParams): Promise<RateLimitResult> {
return getRateLimiter().limit(params)
}
/**
* 重置指定 key 的计数。
*
* 用于成功登录后清除失败计数等场景。
*
* **变更**:返回 `Promise<void>`,调用方需 `await`。
*/
export function resetRateLimit(key: string): Promise<void> {
return getRateLimiter().reset(key)
}

View File

@@ -0,0 +1,78 @@
/**
* 内存滑动窗口速率限制器audit-P1-6
*
* 默认实现,无需额外依赖。
*
* 适用场景:
* - 单实例部署(开发环境、小型站点)
* - 进程内限流(不跨实例共享计数)
*
* 不适用场景:
* - 多实例部署K8s 水平扩容、Serverless 多实例)
* - 需要持久化计数的场景
*
* 多实例部署时请通过 `RATE_LIMIT_DRIVER=redis` 切换至 `RedisRateLimiter`。
*/
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
interface RateLimitEntry {
count: number
resetTime: number
}
/**
* 内存实现的速率限制器。
*
* 使用单个进程级 `Map` 存储计数,懒清理过期项保持 Map 有界。
* 虽然内部操作是同步的,但 `limit` / `reset` 仍返回 Promise以兼容 `RateLimiter` 接口。
*/
export class MemoryRateLimiter implements RateLimiter {
private readonly store = new Map<string, RateLimitEntry>()
/** 清理已过期的桶,避免 Map 无限增长。每次 `limit` 调用时执行。 */
private pruneExpired(now: number): void {
if (this.store.size === 0) return
for (const [key, entry] of this.store.entries()) {
if (entry.resetTime <= now) {
this.store.delete(key)
}
}
}
async limit(params: RateLimitParams): Promise<RateLimitResult> {
const now = Date.now()
this.pruneExpired(now)
const existing = this.store.get(params.key)
if (!existing || existing.resetTime <= now) {
// 新窗口
const resetTime = now + params.windowMs
this.store.set(params.key, { count: 1, resetTime })
return {
success: true,
remaining: params.limit - 1,
resetTime,
retryAfterMs: 0,
}
}
// 窗口内累加计数
existing.count += 1
const remaining = Math.max(0, params.limit - existing.count)
const success = existing.count <= params.limit
const retryAfterMs = success ? 0 : existing.resetTime - now
return {
success,
remaining,
resetTime: existing.resetTime,
retryAfterMs,
}
}
async reset(key: string): Promise<void> {
this.store.delete(key)
}
}

View File

@@ -0,0 +1,193 @@
/**
* Redis 分布式速率限制器audit-P1-6
*
* 基于 `@upstash/ratelimit` + `@upstash/redis` 的滑动窗口实现。
*
* 适用场景:
* - 多实例部署K8s 水平扩容、Serverless 多实例)
* - 需要跨实例共享计数的限流
*
* 使用前提:
* - 项目需安装 `@upstash/ratelimit` 与 `@upstash/redis`
* - 环境变量需配置 `UPSTASH_REDIS_REST_URL` 与 `UPSTASH_REDIS_REST_TOKEN`
*
* 依赖通过动态 import 加载,未启用 Redis 时不会引入这两个包的体积。
* 若启用但未安装依赖,将在首次调用时抛错。
*
* upstash/ratelimit 的 `Ratelimit` 实例在构造时需固定 `max` 与 `window`
* 因此本实现按 `${limit}:${windowMs}` 签名缓存多个 Ratelimit 实例,
* 同一规则的所有调用复用同一实例。
*/
import { env } from "@/env.mjs"
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
/** 毫秒转 upstash `Ratelimit.slidingWindow` 接受的字符串时间单位 */
function msToSlidingWindowArg(ms: number): string {
if (ms % (60 * 1000) === 0) {
const minutes = ms / (60 * 1000)
return `${minutes} m`
}
if (ms % 1000 === 0) {
const seconds = ms / 1000
return `${seconds} s`
}
return `${ms} ms`
}
/** upstash ratelimit 调用返回的结果类型(最小可用子集) */
interface UpstashRatelimitResult {
success: boolean
remaining: number
reset: number
}
/** upstash Ratelimit 实例的最小可用接口 */
interface UpstashRatelimitInstance {
limit: (identifier: string) => Promise<UpstashRatelimitResult>
reset?: (identifier: string) => Promise<void>
}
/** 动态加载后获得的 Ratelimit 构造器 */
interface UpstashRatelimitCtor {
new (config: {
redis: unknown
limiter: unknown
analytics?: boolean
prefix?: string
}): UpstashRatelimitInstance
slidingWindow: (
max: number,
window: string,
) => { type: "sliding-window"; max: number; window: string }
}
/**
* Redis 实现的速率限制器。
*
* 通过动态 import 加载 `@upstash/ratelimit` 与 `@upstash/redis`
* 避免在内存模式下引入这两个包的体积。
*
* 故障策略:
* - 若 Redis 调用抛错,返回 `{ success: true, remaining: limit - 1 }` 兜底,
* 避免限流后端故障导致主流程被阻断(限流降级原则)
* - 配置缺失(未设置 UPSTASH_REDIS_REST_URL/TOKEN时在构造时即抛错
*/
export class RedisRateLimiter implements RateLimiter {
/** 按 `${limit}:${windowMs}` 缓存 Ratelimit 实例,避免重复构造 */
private readonly instances = new Map<string, UpstashRatelimitInstance>()
/** 共享的 Redis 客户端,避免重复连接 */
private redisClient: unknown = null
/** 加载 Ratelimit 构造器的 Promise懒加载 */
private ratelimitCtorPromise: Promise<UpstashRatelimitCtor> | null = null
constructor() {
if (!env.UPSTASH_REDIS_REST_URL || !env.UPSTASH_REDIS_REST_TOKEN) {
throw new Error(
"RedisRateLimiter requires UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. " +
"Set RATE_LIMIT_DRIVER=memory to use in-memory limiter instead.",
)
}
}
/** 懒加载 @upstash/redis 的 Redis 客户端 */
private async getRedisClient(): Promise<unknown> {
if (this.redisClient) return this.redisClient
const { Redis } = await import("@upstash/redis")
this.redisClient = new Redis({
url: env.UPSTASH_REDIS_REST_URL!,
token: env.UPSTASH_REDIS_REST_TOKEN!,
})
return this.redisClient
}
/** 懒加载 @upstash/ratelimit 的 Ratelimit 构造器 */
private async getRatelimitCtor(): Promise<UpstashRatelimitCtor> {
if (this.ratelimitCtorPromise) return this.ratelimitCtorPromise
this.ratelimitCtorPromise = (async () => {
const mod = await import("@upstash/ratelimit")
return mod.Ratelimit as unknown as UpstashRatelimitCtor
})()
return this.ratelimitCtorPromise
}
/** 按 (limit, windowMs) 获取或创建 Ratelimit 实例 */
private async getRatelimit(
params: RateLimitParams,
): Promise<UpstashRatelimitInstance> {
const signature = `${params.limit}:${params.windowMs}`
const existing = this.instances.get(signature)
if (existing) return existing
const [Ratelimit, redis] = await Promise.all([
this.getRatelimitCtor(),
this.getRedisClient(),
])
const instance = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(
params.limit,
msToSlidingWindowArg(params.windowMs),
),
analytics: false,
// 加 prefix 避免与其它用途的 Redis key 冲突
prefix: "next-edu:rl",
})
this.instances.set(signature, instance)
return instance
}
async limit(params: RateLimitParams): Promise<RateLimitResult> {
try {
const ratelimit = await this.getRatelimit(params)
const result = await ratelimit.limit(params.key)
const now = Date.now()
const resetTime =
typeof result.reset === "number" && result.reset > 0
? result.reset * 1000
: now + params.windowMs
const retryAfterMs = result.success ? 0 : Math.max(0, resetTime - now)
return {
success: result.success,
remaining: result.remaining,
resetTime,
retryAfterMs,
}
} catch (error) {
// 限流后端故障时降级为放行,避免阻断主流程
console.error(
"[rate-limit] Redis backend failure, falling back to allow:",
error instanceof Error ? error.message : String(error),
)
const resetTime = Date.now() + params.windowMs
return {
success: true,
remaining: Math.max(0, params.limit - 1),
resetTime,
retryAfterMs: 0,
}
}
}
async reset(key: string): Promise<void> {
try {
// 找到包含该 key 的实例实例按规则签名缓存key 可能命中多个)
// 由于 upstash/ratelimit 的 reset 接受 identifier遍历所有实例重置
for (const instance of this.instances.values()) {
if (typeof instance.reset === "function") {
await instance.reset(key)
}
}
} catch (error) {
// reset 失败不阻断主流程
console.error(
"[rate-limit] Redis reset failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* 速率限制纯函数与常量audit-P1-6
*
* 此文件仅包含与具体后端实现无关的纯函数和常量:
* - `RATE_LIMIT_RULES`:常用场景的预定义规则
* - `rateLimitKey`:限流 key 构造器
* - `rateLimitHeaders`:将结果转换为标准 HTTP 限流响应头
*
* 这些函数不依赖任何后端实现,可在所有 runtime含 edge使用。
*/
import type { RateLimitResult } from "./types"
/**
* 预定义的速率限制规则。
*
* 时间单位均为毫秒。
*/
export const RATE_LIMIT_RULES = {
LOGIN: { limit: 5, windowMs: 15 * 60 * 1000 }, // 15 分钟内 5 次
API: { limit: 100, windowMs: 60 * 1000 }, // 每分钟 100 次
UPLOAD: { limit: 10, windowMs: 60 * 1000 }, // 每分钟 10 次
AI_CHAT: { limit: 20, windowMs: 60 * 1000 }, // 每分钟 20 次
PASSWORD_CHANGE: { limit: 5, windowMs: 60 * 1000 }, // 每分钟 5 次
/**
* audit-P1-8家长绑定子女速率限制。
*
* 三因子验证(生日 365 × 手机后4 10000 = 3.65M 组合)虽然空间大,
* 但单次 onboarding Action 内可循环调用 10 次children.length ≤ 10
* 无独立速率限制时被撤销子女关系的家长可反复尝试枚举绑定。
*
* 限制每小时 5 次5 次完整 onboarding 提交,每次最多 10 个子女 = 50 次尝试/小时),
* 远低于枚举 3.65M 组合所需量级,同时不误伤正常用户(家长极少在 1 小时内反复提交)。
*/
ONBOARDING_BIND: { limit: 5, windowMs: 60 * 60 * 1000 }, // 每小时 5 次
} as const
/**
* 由前缀与标识符构造限流 key。
*
* 例如 `rateLimitKey("login", "1.2.3.4:user@example.com")` -> `"login:1.2.3.4:user@example.com"`。
*/
export function rateLimitKey(prefix: string, identifier: string): string {
return `${prefix}:${identifier}`
}
/**
* 将限流结果转换为标准 HTTP 限流响应头。
*
* 符合 IETF draft-ietf-httpapi-ratelimit-headers 规范:
* - `X-RateLimit-Limit`:窗口内最大次数
* - `X-RateLimit-Remaining`:剩余可用次数
* - `X-RateLimit-Reset`窗口重置时间Unix 秒)
* - `Retry-After`:失败时距重置的秒数
*/
export function rateLimitHeaders(
result: RateLimitResult,
): Record<string, string> {
return {
"X-RateLimit-Limit": String(result.remaining + (result.success ? 1 : 0)),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetTime / 1000)),
...(result.success
? {}
: { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}

View File

@@ -0,0 +1,44 @@
/**
* 速率限制器接口与共享类型定义audit-P1-6
*
* 设计目标:
* - 抽象 `RateLimiter` 接口,使限流后端可在内存与 Redis 之间切换
* - 默认内存实现,无需引入额外依赖
* - 通过 `RATE_LIMIT_DRIVER=redis` 切换至分布式实现,适配多实例部署
* - 公共 API`rateLimit` / `resetRateLimit`)统一返回 Promise保证实现可替换
*/
/** 单次限流检查的入参(与具体后端无关) */
export interface RateLimitParams {
/** 限流桶的唯一标识,例如 `login:${ip}` 或 `ai:${userId}` */
key: string
/** 窗口内允许的最大请求次数 */
limit: number
/** 窗口长度(毫秒) */
windowMs: number
}
/** 限流检查结果(与 upstash `RatelimitResult` 形状兼容,便于平滑切换) */
export interface RateLimitResult {
success: boolean
remaining: number
resetTime: number
/** 距离窗口重置的毫秒数;已重置时为 0 */
retryAfterMs: number
}
/**
* 速率限制器抽象接口。
*
* 所有实现必须为 Promise 返回,以便 Redis 等异步后端无缝接入。
* 实现需保证:
* - `limit()` 无论成功失败均累加计数(便于失败次数累计锁定)
* - `reset()` 幂等,重复调用不抛错
* - 不抛错:失败时返回 `{ success: true }` 兜底,避免限流故障阻断主流程
*/
export interface RateLimiter {
/** 检查并累加请求计数;返回当前窗口状态 */
limit(params: RateLimitParams): Promise<RateLimitResult>
/** 重置指定 key 的计数(例如登录成功后清除失败计数) */
reset(key: string): Promise<void>
}

View File

@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Upstash SDK 可选依赖类型声明audit-P1-6
*
* 这两个包通过动态 import 在运行时加载,仅在 `RATE_LIMIT_DRIVER=redis`
* 时才需要安装。默认内存模式下无需安装。
*
* 安装命令:
* npm install @upstash/ratelimit @upstash/redis
*
* 安装后这两个包自带的类型声明将覆盖此处的 any 声明,
* 提供完整的类型检查。
*/
declare module "@upstash/ratelimit" {
export class Ratelimit {
constructor(config: {
redis: any
limiter: any
analytics?: boolean
prefix?: string
})
limit(
identifier: string,
): Promise<{
success: boolean
limit: number
remaining: number
reset: number
pending: Promise<unknown>
}>
reset(identifier: string): Promise<void>
static slidingWindow(
max: number,
window: string,
): { type: "sliding-window"; max: number; window: string }
static fixedWindow(
max: number,
window: string,
): { type: "fixed-window"; max: number; window: string }
static tokenBucket(
max: number,
window: string,
refillRate: number,
): { type: "token-bucket"; max: number; window: string; refillRate: number }
}
}
declare module "@upstash/redis" {
export class Redis {
constructor(config: {
url: string
token: string
automaticDeserialization?: boolean
responseEncoding?: "base64" | "none"
retry?: {
retries: number
backoff: (retryCount: number) => number
}
})
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<"OK">
del(...keys: string[]): Promise<number>
incr(key: string): Promise<number>
expire(key: string, seconds: number): Promise<number>
pexpire(key: string, milliseconds: number): Promise<number>
[key: string]: any
}
}

View File

@@ -0,0 +1,60 @@
"use client"
import type { ActionState } from "@/shared/types/action-state"
/**
* 根据 ActionState 的 errorCode 解析本地化错误消息。
*
* 优先级:
* 1. errorCode 以 `invalid_date:` 开头 → 查 `errors.invalid_{field}` i18n 键
* 2. errorCode 匹配已知通用码unexpected/validation_error/not_found/permission_denied→ 查对应 i18n 键
* 3. 回退到 result.message
* 4. 最终回退到 fallback
*
* @example
* ```tsx
* const t = useTranslations("attendance")
* const msg = resolveActionError(result, t, t("errors.unexpected"))
* toast.error(msg)
* ```
*/
export function resolveActionError<T>(
result: ActionState<T> | null,
t: (key: string) => string,
fallback: string
): string {
if (!result) return fallback
const code = result.errorCode
// 日期格式错误invalid_date:date / invalid_date:startDate / invalid_date:endDate
if (code?.startsWith("invalid_date:")) {
const field = code.split(":")[1]
const key = `errors.invalid_${field}`
try {
const translated = t(key)
if (translated && translated !== key) return translated
} catch {
// 键不存在,回退
}
}
// 通用错误码映射
const codeMap: Record<string, string> = {
unexpected: "errors.unexpected",
validation_error: "errors.invalidForm",
not_found: "errors.notFound",
permission_denied: "errors.insufficientPermissions",
}
if (code && codeMap[code]) {
try {
const translated = t(codeMap[code])
if (translated && translated !== codeMap[code]) return translated
} catch {
// 键不存在,回退
}
}
return result.message || fallback
}

View File

@@ -1,34 +1,50 @@
/**
* Role normalization utilities (pure functions).
*
* These helpers map various role names (including legacy aliases) to the
* canonical K12 role set: admin / teacher / student / parent.
* These helpers map various role names to the canonical K12 role set.
* `grade_head` and `teaching_head` are preserved as distinct roles (they
* carry different data-scope semantics from `teacher`).
*/
export type NormalizedRole = "admin" | "teacher" | "student" | "parent"
export type NormalizedRole =
| "admin"
| "grade_head"
| "teaching_head"
| "teacher"
| "student"
| "parent"
/**
* Normalize a single role value to one of the canonical roles.
* Legacy aliases such as `grade_head` / `teaching_head` collapse to `teacher`.
* Unknown values fall back to `student`.
*/
export const normalizeRole = (value: unknown): NormalizedRole => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
if (
role === "admin" ||
role === "grade_head" ||
role === "teaching_head" ||
role === "teacher" ||
role === "student" ||
role === "parent"
) {
return role
}
return "student"
}
/**
* Given a list of role names (e.g. from `users_to_roles`), resolve the
* primary role used for routing/permission checks. Priority order:
* admin > teacher > parent > student.
* admin > grade_head > teaching_head > teacher > student > parent.
*/
export const resolvePrimaryRole = (roleNames: string[]): NormalizedRole => {
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("grade_head")) return "grade_head"
if (mapped.includes("teaching_head")) return "teaching_head"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
if (mapped.includes("parent")) return "parent"
return "student"
}

View File

@@ -0,0 +1,83 @@
/**
* 路由权限配置audit-P1-10 + audit-P1-12
*
* 从 proxy.ts 抽取的路由权限常量,实现配置驱动设计。
* 新增路由或权限点时只需修改此文件,无需改 proxy.ts 逻辑。
*
* 匹配优先级proxy.ts 按此顺序检查):
* 1. 精确路由权限SPECIFIC_ROUTE_PERMISSIONS—— 完整路径匹配,优先级最高
* 用于将 /admin/* 下特定页面开放给非管理员角色
* 2. 路由前缀权限ROUTE_PREFIX_PERMISSIONS—— 前缀匹配
* 用于角色路由组(/admin、/teacher、/student、/parent、/management
*
* audit-P1-10 扩展:原仅 /admin/ai-settings、/admin/roles、/admin/permissions 三个精确路由例外,
* 现新增 /admin/announcements、/admin/audit-logs、/admin/elective、/admin/questions、
* /admin/users、/admin/error-book、/admin/lesson-plans、/admin/course-plans 等子路径细粒度权限,
* 使拥有对应权限的非管理员角色(如教师)可直接访问 /admin/* 下相关页面。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
/**
* 精确路由权限(优先级最高,覆盖前缀匹配)。
*
* 用于将 /admin/* 下的特定页面开放给非管理员角色。
* key 为完整路径(不含 query stringvalue 为所需权限点。
*
* 注意:此表仅控制"是否允许访问此路径"
* 页面内的 requirePermission() 仍按各自模块的权限点校验。
*/
export const SPECIFIC_ROUTE_PERMISSIONS: Record<string, Permission> = {
// V3.1/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider
"/admin/ai-settings": Permissions.AI_CHAT,
// RBAC/admin/roles 和 /admin/permissions 使用细粒度权限而非 /admin 前缀的 SCHOOL_MANAGE
"/admin/roles": Permissions.ROLE_READ,
"/admin/permissions": Permissions.PERMISSION_READ,
// audit-P1-10以下子路径细粒度权限允许非管理员角色访问
"/admin/announcements": Permissions.ANNOUNCEMENT_MANAGE,
"/admin/audit-logs": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/overview": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/login-logs": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/data-changes": Permissions.AUDIT_LOG_READ,
"/admin/elective": Permissions.ELECTIVE_MANAGE,
"/admin/questions": Permissions.QUESTION_READ,
"/admin/users": Permissions.USER_MANAGE,
// audit-P2-3: 邀请码管理路由USER_MANAGE 权限,复用用户管理权限)
"/admin/invitation-codes": Permissions.USER_MANAGE,
"/admin/error-book": Permissions.ERROR_BOOK_ANALYTICS_READ,
"/admin/lesson-plans": Permissions.LESSON_PLAN_READ,
"/admin/course-plans": Permissions.COURSE_PLAN_READ,
}
/**
* 路由前缀权限(精确路由未命中时按前缀匹配)。
*
* key 为路由前缀value 为所需权限点。
* 按 Object.entries 顺序检查,命中第一个前缀即停止。
*/
export const ROUTE_PREFIX_PERMISSIONS: Record<string, Permission> = {
"/admin": Permissions.SCHOOL_MANAGE,
"/teacher": Permissions.EXAM_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/parent": Permissions.DASHBOARD_PARENT_READ,
"/management": Permissions.GRADE_MANAGE,
}
/**
* 仪表盘路由的细粒度权限(覆盖前缀匹配,防止跨角色访问仪表盘)。
*
* 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等。
*/
export const DASHBOARD_ROUTE_PERMISSIONS: Record<string, Permission> = {
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
/**
* API 路由前缀权限。
*/
export const API_ROUTE_PERMISSIONS: Record<string, Permission> = {
"/api/ai/chat": Permissions.AI_CHAT,
}

View File

@@ -0,0 +1,35 @@
/**
* 角色到默认路由的解析P0-6 解耦修复)
*
* 抽取自 `proxy.ts` 与 `onboarding/data-access.ts` 中重复的
* `resolveDefaultPath` 实现,确保两处路由解析行为一致。
*
* 优先级admin > grade_head/teaching_head > teacher > student > parent。
* 未知角色回退到 `/dashboard`。
*
* 注意:此模块不导入 `server-only`,可在 proxy.tsedge 运行时)中使用。
*/
import { normalizeRole } from "@/shared/lib/role-utils"
/**
* 根据角色列表解析默认登录后路由。
*
* @param roles 原始角色名数组(来自 JWT/session/DB可为大小写不一致的字符串
* @returns 默认路由路径,如 `/admin/dashboard`
*/
export function resolveDefaultPath(roles: string[]): string {
const normalized = roles.map((r) => normalizeRole(r))
if (normalized.includes("admin")) return "/admin/dashboard"
if (
normalized.includes("grade_head") ||
normalized.includes("teaching_head")
) {
return "/teacher/dashboard"
}
if (normalized.includes("teacher")) return "/teacher/dashboard"
if (normalized.includes("student")) return "/student/dashboard"
if (normalized.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}

View File

@@ -22,10 +22,20 @@ export type EventName =
| "announcement.deleted"
| "announcement.pin_toggled"
| "announcement.marked_read"
| "announcement.action_error"
| "message.sent"
| "message.send_failed"
| "message.deleted"
| "message.marked_read"
| "message.star_toggled"
| "message.recalled"
| "message.template_created"
| "message.group_sent"
| "message.group_send_failed"
| "message.reported"
| "message.report_failed"
| "message.user_blocked"
| "message.user_unblocked"
| "notification.marked_read"
| "notification.marked_all_read"
| "notification.sent"
@@ -36,6 +46,10 @@ export type EventName =
| "attendance.updated"
| "attendance.deleted"
| "attendance.rules_saved"
// L-5 在线请假流程
| "leave_request.created"
| "leave_request.reviewed"
| "leave_request.cancelled"
| "elective.course_created"
| "elective.course_updated"
| "elective.course_deleted"
@@ -62,6 +76,11 @@ export type EventName =
| "homework.submitted"
| "homework.graded"
| "homework.auto_save_failed"
| "homework.excellent_viewed"
| "homework.remind_unsubmitted"
| "homework.started"
| "homework.answer_saved"
| "homework.scan_deleted"
// AI 模块监控事件
| "ai.chat"
| "ai.chat_stream"
@@ -72,6 +91,34 @@ export type EventName =
| "ai.weakness_analysis"
| "ai.child_summary"
| "ai.study_path"
| "ai.explain_error"
// 审计模块监控事件
| "audit.exported"
| "audit.viewed"
| "audit.retention"
// files 模块监控事件
| "file.uploaded"
| "file.upload_failed"
| "file.deleted"
| "file.batch_deleted"
| "file.viewed"
| "file.action_error"
// auth 模块监控事件audit-P1-9
// 登录成功/失败、登出、注册、2FA 启用/禁用、账户锁定、速率限制触发
// 用于监控:登录成功率、异常登录地理/设备告警、2FA 启用率、账户锁定触发率、限流触发率
| "auth.signin_success"
| "auth.signin_failure"
| "auth.signout"
| "auth.signup"
| "auth.2fa_enabled"
| "auth.2fa_disabled"
| "auth.account_locked"
| "auth.rate_limited"
// invitation-codes 模块监控事件audit-P2-3
// 用于监控:邀请码生成/使用/删除、注册转化率(邀请码注册 vs 开放注册)
| "invitation_codes.generated"
| "invitation_codes.consumed"
| "invitation_codes.deleted"
/** 埋点事件负载 */
export interface TrackEventPayload {
@@ -155,3 +202,31 @@ export async function trackExamEvent(
properties: params.properties,
})
}
/**
* auth 模块专用埋点函数audit-P1-9
*
* 封装 trackEvent自动设置 targetType="user",简化 auth.ts / login-service.ts 等调用方代码。
*
* @example
* ```ts
* await trackAuthEvent("auth.signin_success", { userId: user.id, properties: { roles: ["teacher"] } })
* await trackAuthEvent("auth.account_locked", { userId, properties: { attempts: 5 } })
* ```
*/
export async function trackAuthEvent(
event: Extract<EventName, `auth.${string}`>,
params: {
userId?: string
targetId?: string
properties?: Record<string, unknown>
}
): Promise<void> {
await trackEvent({
event,
userId: params.userId,
targetId: params.targetId ?? params.userId,
targetType: "user",
properties: params.properties,
})
}

View File

@@ -0,0 +1,38 @@
/**
* 共享类型守卫工具
*
* 提供通用的运行时类型检查函数,供所有模块复用。
* 避免各模块重复定义 isRecord 等工具函数。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
/**
* 判断值是否为非 null 对象Record
*
* 用作 JSON 解析结果的类型收窄,避免使用 `as` 断言。
*/
export const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
/**
* 所有合法权限值的只读集合,用于运行时校验。
*/
const PERMISSION_VALUES: ReadonlySet<string> = new Set(Object.values(Permissions))
/**
* 类型守卫:判断值是否为合法的 Permission 字符串。
*
* 替代 `value as Permission` 断言,确保运行时安全。
*/
export function isPermission(value: unknown): value is Permission {
return typeof value === "string" && PERMISSION_VALUES.has(value)
}
/**
* 安全转换:将 unknown 转为 Permission | null。
* 合法返回值,非法返回 null。
*/
export function toPermission(value: unknown): Permission | null {
return isPermission(value) ? value : null
}

View File

@@ -3,12 +3,18 @@
*
* - `success` indicates whether the action completed without errors.
* - `message` is an optional human-readable summary (success or failure).
* 注意:面向用户展示的文案应通过 `errorCode` + 前端 i18n 查找,
* `message` 仅用于非结构化的诊断信息或临时占位。
* - `errorCode` carries a structured error code that the frontend can map to
* an i18n key. 优先使用 errorCode 而非硬编码 message。
* - `errors` holds field-level validation errors keyed by field name.
* - `data` carries the action's payload on success.
*/
export type ActionState<T = void> = {
success: boolean
message?: string
/** 结构化错误码,前端通过 t(`errors.${errorCode}`) 查 i18n 翻译 */
errorCode?: string
errors?: Record<string, string[]>
data?: T
}

View File

@@ -2,16 +2,59 @@
// Used by requirePermission() on server and usePermission() on client
/**
* All role names recognized by the system.
* Used to type-check `AuthContext.roles`, `ROLE_PERMISSIONS` keys, and JWT/session payloads.
* Role name. Dynamic — roles are stored in the `roles` DB table and can be
* created/edited/deleted at runtime via the rbac module.
*
* `BuiltinRole` covers the 6 system-defined roles that ship with the app and
* are referenced by name in code paths that need compile-time safety
* (e.g. `resolveDataScope` checks `roleNames.includes("admin")`).
*/
export type Role =
| "admin"
| "teacher"
| "student"
| "parent"
| "grade_head"
| "teaching_head"
export type Role = string
/**
* The 6 system-defined roles. Seeded into the DB with `is_system = true`
* and cannot be deleted. Their permissions can be edited at runtime
* (except for `admin`, which is fully locked).
*/
export const BUILTIN_ROLES = [
"admin",
"teacher",
"student",
"parent",
"grade_head",
"teaching_head",
] as const
export type BuiltinRole = (typeof BUILTIN_ROLES)[number]
/**
* Named role constants for use in DB queries and permission checks.
* Avoids hardcoding role name strings throughout the codebase.
*/
export const ROLE_NAMES = {
ADMIN: "admin",
TEACHER: "teacher",
STUDENT: "student",
PARENT: "parent",
GRADE_HEAD: "grade_head",
TEACHING_HEAD: "teaching_head",
} as const
/**
* Type guard: narrows `string` to `BuiltinRole`.
* Use to filter role names coming from the DB when only built-in roles are meaningful.
*/
export function isBuiltinRole(value: string): value is BuiltinRole {
return (BUILTIN_ROLES as readonly string[]).includes(value)
}
/**
* @deprecated Use `isBuiltinRole` instead. Kept for backward compatibility
* during the RBAC migration; will be removed once all call sites are updated.
*/
export function isRole(value: string): value is Role {
return typeof value === "string"
}
export const Permissions = {
// Exam
@@ -89,6 +132,14 @@ export const Permissions = {
ATTENDANCE_MANAGE: "attendance:manage",
ATTENDANCE_READ: "attendance:read",
// Leave Request (在线请假流程)
/** 创建请假申请(家长代子女提交、学生本人提交) */
LEAVE_REQUEST_CREATE: "leave_request:create",
/** 读取请假申请(本人/子女/所辖班级) */
LEAVE_REQUEST_READ: "leave_request:read",
/** 审批请假申请(班主任、管理员) */
LEAVE_REQUEST_REVIEW: "leave_request:review",
// Message (站内消息)
MESSAGE_SEND: "message:send",
MESSAGE_READ: "message:read",
@@ -117,6 +168,10 @@ export const Permissions = {
LESSON_PLAN_UPDATE: "lesson_plan:update",
LESSON_PLAN_DELETE: "lesson_plan:delete",
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
// Standards (课标管理)
STANDARD_READ: "standard:read",
STANDARD_MANAGE: "standard:manage",
STANDARD_LINK: "standard:link",
// Dashboard (仪表盘 — 各角色独立读权限)
DASHBOARD_ADMIN_READ: "dashboard:admin_read",
@@ -137,27 +192,24 @@ export const Permissions = {
ADAPTIVE_PRACTICE_READ: "adaptive_practice:read",
/** 发起和管理自己的专项练习(学生) */
ADAPTIVE_PRACTICE_MANAGE: "adaptive_practice:manage",
// RBAC (角色与权限管理)
/** 创建新角色 */
ROLE_CREATE: "role:create",
/** 查看角色列表与详情 */
ROLE_READ: "role:read",
/** 编辑角色信息与权限分配 */
ROLE_UPDATE: "role:update",
/** 删除自定义角色(内置角色不可删除) */
ROLE_DELETE: "role:delete",
/** 为用户分配或撤销角色 */
ROLE_ASSIGN: "role:assign",
/** 查看权限点目录 */
PERMISSION_READ: "permission:read",
} as const satisfies Record<string, string>
export type Permission = (typeof Permissions)[keyof typeof Permissions]
const VALID_ROLES: readonly Role[] = [
"admin",
"teacher",
"student",
"parent",
"grade_head",
"teaching_head",
]
/**
* Type guard: narrows `string` to `Role`.
* Use to filter role names coming from the DB or external sources.
*/
export function isRole(value: string): value is Role {
return (VALID_ROLES as readonly string[]).includes(value)
}
/**
* Data scope for row-level security.
*
@@ -184,7 +236,7 @@ export type DataScope =
* Authentication context for the current request.
*
* - `userId`: the authenticated user's id.
* - `roles`: all role names assigned to the user (typed as `Role[]` to catch typos at compile time).
* - `roles`: all role names assigned to the user (dynamic, from DB).
* - `permissions`: the merged set of permission strings granted to the user.
* - `dataScope`: the row-level security scope used to filter queries.
*/

View File

@@ -12,9 +12,12 @@ test.describe("auth smoke", () => {
test("register page renders required controls", async ({ page }) => {
await page.goto("/register")
await expect(page).toHaveTitle(/Register/i)
await expect(page.getByLabel("Full Name")).toBeVisible()
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.getByLabel("Password")).toBeVisible()
await expect(page.getByRole("button", { name: "Create Account" })).toBeVisible()
// 注册表单当前使用中文文案P1 i18n 阶段会切换为 next-intl 翻译键,
// 届时此处的选择器应改用 data-testid 或 i18n 键以确保语言无关。
await expect(page.getByLabel("姓名")).toBeVisible()
await expect(page.getByLabel("邮箱")).toBeVisible()
await expect(page.getByLabel("密码")).toBeVisible()
await expect(page.getByRole("button", { name: "创建账户" })).toBeVisible()
})
})

View File

@@ -0,0 +1,314 @@
import { describe, it, expect } from "vitest"
import {
parseSchedule,
isScheduleConflict,
normalizeDay,
buildLotteryRankCase,
} from "@/modules/elective/data-access-operations"
import { mapCourseRow, type CourseCoreRow } from "@/modules/elective/data-access"
/**
* Elective 模块纯函数单元测试P2-18 新增)。
*
* 覆盖范围:
* - normalizeDay中英文星期归一化
* - parseSchedule时间段解析单时段、多时段、中英文、非法输入
* - isScheduleConflict冲突检测同天重叠、跨天不冲突、归一化后冲突
* - buildLotteryRankCase抽签 rank SQL 构建结构
* - mapCourseRow数据库行 → 业务对象映射
*
* 这些纯函数不依赖 DB可在 vitest node 环境下独立运行。
*/
describe("normalizeDay", () => {
it("maps Chinese 周一-周天 to 1-7", () => {
expect(normalizeDay("周一")).toBe("1")
expect(normalizeDay("周二")).toBe("2")
expect(normalizeDay("周三")).toBe("3")
expect(normalizeDay("周四")).toBe("4")
expect(normalizeDay("周五")).toBe("5")
expect(normalizeDay("周六")).toBe("6")
expect(normalizeDay("周日")).toBe("7")
expect(normalizeDay("周天")).toBe("7")
})
it("maps Chinese 星期一-星期日 to 1-7", () => {
expect(normalizeDay("星期一")).toBe("1")
expect(normalizeDay("星期二")).toBe("2")
expect(normalizeDay("星期日")).toBe("7")
expect(normalizeDay("星期天")).toBe("7")
})
it("maps English full day names case-insensitively", () => {
expect(normalizeDay("monday")).toBe("1")
expect(normalizeDay("Tuesday")).toBe("2")
expect(normalizeDay("WEDNESDAY")).toBe("3")
expect(normalizeDay("Thursday")).toBe("4")
expect(normalizeDay("friday")).toBe("5")
expect(normalizeDay("Saturday")).toBe("6")
expect(normalizeDay("sunday")).toBe("7")
})
it("maps English abbreviations case-insensitively", () => {
expect(normalizeDay("mon")).toBe("1")
expect(normalizeDay("Tue")).toBe("2")
expect(normalizeDay("WED")).toBe("3")
expect(normalizeDay("thu")).toBe("4")
expect(normalizeDay("fri")).toBe("5")
expect(normalizeDay("sat")).toBe("6")
expect(normalizeDay("sun")).toBe("7")
})
it("returns input unchanged for unknown values", () => {
expect(normalizeDay("foo")).toBe("foo")
expect(normalizeDay("")).toBe("")
})
})
describe("parseSchedule", () => {
it("returns empty array for null/empty input", () => {
expect(parseSchedule(null)).toEqual([])
expect(parseSchedule("")).toEqual([])
expect(parseSchedule(" ")).toEqual([])
})
it("parses single Chinese schedule", () => {
const result = parseSchedule("周一 14:00-15:30")
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
day: "周一",
start: "14:00",
end: "15:30",
})
})
it("parses single English full day name", () => {
const result = parseSchedule("Monday 09:00-10:30")
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
day: "Monday",
start: "09:00",
end: "10:30",
})
})
it("parses single English abbreviation", () => {
const result = parseSchedule("Mon 14:00-15:30")
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
day: "Mon",
start: "14:00",
end: "15:30",
})
})
it("parses 星期一 format", () => {
const result = parseSchedule("星期一 08:00-09:00")
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
day: "星期一",
start: "08:00",
end: "09:00",
})
})
it("parses multiple time slots separated by comma", () => {
const result = parseSchedule("周一 14:00-15:30, Wed 16:00-17:30")
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ day: "周一", start: "14:00", end: "15:30" })
expect(result[1]).toEqual({ day: "Wed", start: "16:00", end: "17:30" })
})
it("parses multiple time slots separated by Chinese semicolon", () => {
const result = parseSchedule("周一 14:00-15:30周三 16:00-17:30")
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ day: "周一", start: "14:00", end: "15:30" })
expect(result[1]).toEqual({ day: "周三", start: "16:00", end: "17:30" })
})
it("parses multiple time slots separated by English semicolon", () => {
const result = parseSchedule("Mon 14:00-15:30; Wed 16:00-17:30")
expect(result).toHaveLength(2)
})
it("supports ~ and and 至 as time range separators", () => {
expect(parseSchedule("周一 14:00~15:30")).toHaveLength(1)
expect(parseSchedule("周一 14:0015:30")).toHaveLength(1)
expect(parseSchedule("周一 14:00至15:30")).toHaveLength(1)
})
it("pads single-digit hour with leading zero", () => {
const result = parseSchedule("周一 9:00-10:30")
expect(result).toHaveLength(1)
expect(result[0]?.start).toBe("09:00")
expect(result[0]?.end).toBe("10:30")
})
it("returns empty array for invalid format", () => {
expect(parseSchedule("invalid")).toEqual([])
expect(parseSchedule("周一")).toEqual([])
expect(parseSchedule("周一 14:00")).toEqual([])
expect(parseSchedule("周一 invalid-invalid")).toEqual([])
})
it("skips invalid segments in multi-slot input but keeps valid ones", () => {
const result = parseSchedule("周一 14:00-15:30, invalid, Wed 16:00-17:30")
expect(result).toHaveLength(2)
})
})
describe("isScheduleConflict", () => {
it("returns false for different days", () => {
const a = { day: "周一", start: "14:00", end: "15:30" }
const b = { day: "周二", start: "14:00", end: "15:30" }
expect(isScheduleConflict(a, b)).toBe(false)
})
it("returns true for overlapping time on same day (Chinese)", () => {
const a = { day: "周一", start: "14:00", end: "15:30" }
const b = { day: "周一", start: "15:00", end: "16:00" }
expect(isScheduleConflict(a, b)).toBe(true)
})
it("returns false for adjacent non-overlapping on same day", () => {
const a = { day: "周一", start: "14:00", end: "15:30" }
const b = { day: "周一", start: "15:30", end: "17:00" }
expect(isScheduleConflict(a, b)).toBe(false)
})
it("normalizes day before comparison (周一 vs Mon)", () => {
const a = { day: "周一", start: "14:00", end: "15:30" }
const b = { day: "Mon", start: "15:00", end: "16:00" }
expect(isScheduleConflict(a, b)).toBe(true)
})
it("normalizes day before comparison (Monday vs 星期一)", () => {
const a = { day: "Monday", start: "14:00", end: "15:30" }
const b = { day: "星期一", start: "15:00", end: "16:00" }
expect(isScheduleConflict(a, b)).toBe(true)
})
it("returns false for non-overlapping on same day", () => {
const a = { day: "周一", start: "08:00", end: "09:00" }
const b = { day: "周一", start: "14:00", end: "15:30" }
expect(isScheduleConflict(a, b)).toBe(false)
})
it("handles contained intervals", () => {
const a = { day: "周一", start: "14:00", end: "17:00" }
const b = { day: "周一", start: "15:00", end: "16:00" }
expect(isScheduleConflict(a, b)).toBe(true)
})
})
describe("buildLotteryRankCase", () => {
it("returns SQL object for empty input", () => {
const sql = buildLotteryRankCase([], 1)
expect(sql).toBeDefined()
})
it("returns SQL object for non-empty input", () => {
const sql = buildLotteryRankCase(["id1", "id2", "id3"], 5)
expect(sql).toBeDefined()
})
it("preserves order and start rank", () => {
// SQL 对象本身不可直接断言内部结构,但应能成功构建
const ids = ["a", "b", "c", "d"]
const sql = buildLotteryRankCase(ids, 10)
expect(sql).toBeDefined()
// 不抛异常即视为通过;实际 SQL 执行由集成测试覆盖
})
})
describe("mapCourseRow", () => {
// 构造一个最小的 mock 行(仅包含 mapCourseRow 使用的字段)
const mockRow: CourseCoreRow = {
id: "course-1",
name: "Python 入门",
subjectId: "subj-1",
teacherId: "user-1",
gradeId: "grade-1",
description: "Python 编程基础",
capacity: 30,
enrolledCount: 25,
classroom: "A101",
schedule: "周一 14:00-15:30",
startDate: new Date("2026-09-01"),
endDate: new Date("2027-01-15"),
selectionStartAt: new Date("2026-08-20T08:00:00Z"),
selectionEndAt: new Date("2026-08-25T18:00:00Z"),
status: "open",
selectionMode: "lottery",
credit: "2.0",
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-15T12:00:00Z"),
}
it("maps basic fields correctly", () => {
const result = mapCourseRow(
mockRow,
new Map(),
new Map(),
new Map()
)
expect(result.id).toBe("course-1")
expect(result.name).toBe("Python 入门")
expect(result.capacity).toBe(30)
expect(result.enrolledCount).toBe(25)
expect(result.status).toBe("open")
expect(result.selectionMode).toBe("lottery")
})
it("converts credit to string", () => {
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
expect(result.credit).toBe("2.0")
expect(typeof result.credit).toBe("string")
})
it("formats startDate as YYYY-MM-DD", () => {
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
expect(result.startDate).toBe("2026-09-01")
})
it("converts timestamps to ISO strings", () => {
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
expect(result.createdAt).toBe("2026-06-01T00:00:00.000Z")
expect(result.updatedAt).toBe("2026-06-15T12:00:00.000Z")
expect(result.selectionStartAt).toBe("2026-08-20T08:00:00.000Z")
})
it("resolves teacherName from map", () => {
const teacherNames = new Map([["user-1", "张老师"]])
const result = mapCourseRow(mockRow, teacherNames, new Map(), new Map())
expect(result.teacherName).toBe("张老师")
})
it("returns null teacherName when teacherId not in map", () => {
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
expect(result.teacherName).toBeNull()
})
it("resolves subjectName and gradeName from maps", () => {
const teacherNames = new Map([["user-1", "张老师"]])
const subjectNames = new Map([["subj-1", "计算机"]])
const gradeNames = new Map([["grade-1", "高一"]])
const result = mapCourseRow(mockRow, teacherNames, subjectNames, gradeNames)
expect(result.subjectName).toBe("计算机")
expect(result.gradeName).toBe("高一")
})
it("returns null subjectName/gradeName when ids are null", () => {
const rowWithNulls: CourseCoreRow = {
...mockRow,
subjectId: null,
gradeId: null,
}
const result = mapCourseRow(rowWithNulls, new Map(), new Map(), new Map())
expect(result.subjectName).toBeNull()
expect(result.gradeName).toBeNull()
expect(result.subjectId).toBeNull()
expect(result.gradeId).toBeNull()
})
})

View File

@@ -0,0 +1,2 @@
// 测试环境空模块,用于替代 server-only 守卫
export {}

224
tmp_append_en.ps1 Normal file
View File

@@ -0,0 +1,224 @@
$path = "e:\Desktop\CICD\src\shared\i18n\messages\en\lesson-preparation.json"
$content = Get-Content -Raw $path
# Find the final closing brace of the "confirm" object + outer object
$marker = ' "archiveTitle": "Confirm Archive"' + "`r`n" + ' }' + "`r`n" + '}'
$markerLF = ' "archiveTitle": "Confirm Archive"' + "`n" + ' }' + "`n" + '}'
$appendix = @'
},
"calendar": {
"title": "Lesson Plan Calendar",
"description": "View lesson preparation activities by date",
"weekView": "Week",
"monthView": "Month",
"today": "Today",
"prev": "Previous",
"next": "Next",
"noEvents": "No activities on this day",
"loading": "Loading...",
"loadFailed": "Failed to load calendar",
"eventType": {
"created": "Created",
"updated": "Updated",
"version_saved": "Version Saved",
"submitted": "Submitted",
"published": "Published",
"reviewed": "Reviewed"
},
"eventMeta": "v{versionNo}",
"weekDays": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
}
},
"review": {
"title": "Review Workflow",
"submit": "Submit for Review",
"withdraw": "Withdraw Submission",
"approve": "Approve",
"reject": "Reject",
"submitConfirm": "Submit for review? You cannot edit after submission until reviewed.",
"withdrawConfirm": "Withdraw submission? The plan will return to draft status.",
"approveConfirm": "Approve this lesson plan?",
"rejectConfirm": "Reject this lesson plan? The teacher can revise and resubmit.",
"commentLabel": "Review Comment",
"commentPlaceholder": "Enter review comment (optional)",
"submitted": "Submitted for Review",
"approved": "Approved",
"rejected": "Rejected",
"withdrawn": "Withdrawn",
"reviewerLabel": "Reviewer: {name}",
"reviewAt": "Reviewed at: {time}",
"records": "Review Records",
"noRecords": "No review records",
"pendingTitle": "Pending Review",
"pendingEmpty": "No pending reviews",
"invalidTransition": "Invalid status transition",
"notSubmitted": "Cannot submit for review in current status",
"notReviewable": "Cannot review in current status",
"submitSuccess": "Submitted for review",
"withdrawSuccess": "Submission withdrawn",
"approveSuccess": "Approved",
"rejectSuccess": "Rejected"
},
"comment": {
"title": "Collaboration Comments",
"add": "Post Comment",
"reply": "Reply",
"placeholder": "Enter comment...",
"empty": "No comments",
"resolve": "Mark as Resolved",
"reopen": "Reopen",
"resolved": "Resolved",
"unresolved": "Unresolved",
"delete": "Delete",
"deleteConfirm": "Delete this comment? Replies will also be deleted.",
"edit": "Edit",
"unresolvedCount": "{count} unresolved",
"blockLabel": "Block: {title}",
"createSuccess": "Comment posted",
"deleteSuccess": "Deleted",
"resolveSuccess": "Marked as resolved",
"createFailed": "Failed to post comment",
"deleteFailed": "Failed to delete comment"
},
"formative": {
"title": "Formative Assessment",
"add": "Add Interaction",
"empty": "No interactions",
"interactionType": {
"poll": "Poll",
"quiz": "Quiz",
"exit_ticket": "Exit Ticket"
},
"promptLabel": "Prompt",
"promptPlaceholder": "Enter prompt...",
"optionsLabel": "Options",
"addOption": "+ Add Option",
"correctAnswer": "Correct Answer",
"durationLabel": "Duration (sec)",
"responses": "Student Responses",
"noResponses": "No responses",
"stats": "Statistics",
"totalResponses": "{count} responses",
"correctRate": "Correct rate {rate}%",
"avgDuration": "Avg {sec} sec",
"createSuccess": "Added",
"updateSuccess": "Updated",
"deleteSuccess": "Deleted"
},
"attachment": {
"title": "Attachments",
"add": "Add Attachment",
"empty": "No attachments",
"type": {
"reference": "Reference",
"material": "Material",
"supplementary": "Supplementary"
},
"nameLabel": "Name",
"urlLabel": "URL",
"typeLabel": "Type",
"createSuccess": "Attachment added",
"deleteSuccess": "Attachment deleted",
"updateSuccess": "Attachment updated"
},
"substitute": {
"title": "Substitute Teacher",
"add": "Add Substitute",
"empty": "No substitute records",
"originalTeacher": "Original Teacher",
"substituteTeacher": "Substitute Teacher",
"startDate": "Start Date",
"endDate": "End Date",
"status": {
"active": "Active",
"expired": "Expired",
"cancelled": "Cancelled"
},
"createSuccess": "Substitute arranged",
"updateSuccess": "Substitute status updated",
"deleteSuccess": "Substitute deleted",
"cancelConfirm": "Cancel this substitute arrangement?"
},
"evaluation": {
"title": "AI Evaluation",
"run": "Run Evaluation",
"running": "Evaluating...",
"empty": "No evaluation records",
"overallScore": "Overall Score",
"dimensions": {
"clarity": "Clarity",
"alignment": "Standards Alignment",
"engagement": "Engagement",
"differentiation": "Differentiation",
"assessment": "Assessment"
},
"suggestions": "Suggestions",
"noSuggestions": "No suggestions",
"createSuccess": "Evaluation completed",
"suggestion": {
"addMoreNodeDetails": "Add more details in teaching nodes",
"linkMoreStandards": "Link more curriculum standards",
"addFormativeItems": "Add classroom interactions",
"addPracticeBlock": "Add practice block",
"addHomeworkBlock": "Specify homework requirements"
}
},
"analytics": {
"title": "Analytics",
"teacherInvestment": "Teacher Investment",
"templateUsage": "Template Usage",
"standardsCoverage": "Standards Coverage",
"globalStats": "Overview",
"totalTeachers": "Teachers",
"totalPlans": "Total Plans",
"totalPublished": "Published",
"totalSubmitted": "Pending Review",
"totalStandardsLinked": "Standards Linked",
"noData": "No data",
"loadFailed": "Failed to load analytics"
},
"standards": {
"title": "Standards Alignment",
"link": "Link Standard",
"unlink": "Unlink",
"linked": "{count} linked",
"empty": "No linked standards",
"searchPlaceholder": "Search standards...",
"level": {
"national": "National",
"curriculum": "Curriculum",
"custom": "Custom"
},
"linkSuccess": "Standard linked",
"unlinkSuccess": "Standard unlinked"
}
}
'@
$matched = $false
if ($content.Contains($marker)) {
$newContent = $content.Replace($marker, ' "archiveTitle": "Confirm Archive"' + "`r`n" + $appendix)
$matched = $true
Write-Output "Matched CRLF marker"
} elseif ($content.Contains($markerLF)) {
$newContent = $content.Replace($markerLF, ' "archiveTitle": "Confirm Archive"' + "`n" + $appendix)
$matched = $true
Write-Output "Matched LF marker"
}
if ($matched) {
Set-Content -Path $path -Value $newContent -NoNewline -Encoding UTF8
Write-Output "File written successfully. New size: $($newContent.Length)"
} else {
Write-Output "No marker matched"
Write-Output "Last 80 chars of content:"
Write-Output $content.Substring($content.Length - 80)
}

0
tmp_merge_en.ps1 Normal file
View File

0
update-md.cjs Normal file
View File