feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
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:
139
src/shared/components/class-filter.tsx
Normal file
139
src/shared/components/class-filter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/shared/components/error-state.tsx
Normal file
35
src/shared/components/error-state.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
44
src/shared/components/route-error.tsx
Normal file
44
src/shared/components/route-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
src/shared/components/section-error-boundary.tsx
Normal file
148
src/shared/components/section-error-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/shared/components/ui/alert.tsx
Normal file
42
src/shared/components/ui/alert.tsx
Normal 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 }
|
||||
155
src/shared/components/widget-boundary.tsx
Normal file
155
src/shared/components/widget-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
src/shared/constants/attendance-status.ts
Normal file
96
src/shared/constants/attendance-status.ts
Normal 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"
|
||||
)
|
||||
}
|
||||
@@ -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: 群组消息 ID(fan-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/rejected;approved/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(),
|
||||
/** 审批人 ID(pending/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: 当前持有编辑锁的教师 ID(null 表示无锁)
|
||||
* - 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(),
|
||||
/** 复核教师 ID(pending 状态为 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),
|
||||
}));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
80
src/shared/i18n/messages/en/invitation-codes.json
Normal file
80
src/shared/i18n/messages/en/invitation-codes.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"users": "Users",
|
||||
"userList": "User List",
|
||||
"importUsers": "Import Users",
|
||||
"roles": "Roles",
|
||||
"permissions": "Permissions",
|
||||
"teaching": "Teaching",
|
||||
"coursePlans": "Course Plans",
|
||||
"electives": "Electives",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"message": "Message",
|
||||
"announcement": "Announcement",
|
||||
"homework": "Homework",
|
||||
"grade": "Grade"
|
||||
"grade": "Grade",
|
||||
"diagnostic": "Diagnostic"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All"
|
||||
|
||||
22
src/shared/i18n/messages/en/parent.json
Normal file
22
src/shared/i18n/messages/en/parent.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
156
src/shared/i18n/messages/en/questions.json
Normal file
156
src/shared/i18n/messages/en/questions.json
Normal 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"
|
||||
}
|
||||
}
|
||||
318
src/shared/i18n/messages/en/rbac.json
Normal file
318
src/shared/i18n/messages/en/rbac.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "图表数据格式错误,无法渲染"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 页"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "考勤已记录",
|
||||
|
||||
@@ -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": "清理日志失败。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "邮箱或密码错误",
|
||||
|
||||
@@ -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": "我的课表"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,5 +67,20 @@
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "首页"
|
||||
},
|
||||
"widget": {
|
||||
"error": "组件加载失败",
|
||||
"retry": "重试",
|
||||
"loading": "加载中...",
|
||||
"block": "区块",
|
||||
"loadFailed": "{title}加载失败",
|
||||
"defaultFallback": "请重试或刷新页面",
|
||||
"retryAriaLabel": "重试加载{title}",
|
||||
"loadingAriaLabel": "{title}加载中"
|
||||
},
|
||||
"error": {
|
||||
"boundaryTitle": "区块加载失败",
|
||||
"boundaryDescription": "处理请求时发生错误,请重试。",
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "正在加载课程计划数据..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "滚动查看更多",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}% 阈值),请考虑扩容或关闭选课。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "已添加"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "选择考试模式并配置相关选项。监考模式会启用防作弊监控。",
|
||||
|
||||
@@ -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": "操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "当前分数"
|
||||
}
|
||||
}
|
||||
|
||||
80
src/shared/i18n/messages/zh-CN/invitation-codes.json
Normal file
80
src/shared/i18n/messages/zh-CN/invitation-codes.json
Normal 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": "无权限访问此页面"
|
||||
}
|
||||
}
|
||||
@@ -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": "您尚未提交任何请假申请"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "已取消关联"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "加载消息数据时发生错误,请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"users": "用户",
|
||||
"userList": "用户列表",
|
||||
"importUsers": "导入用户",
|
||||
"roles": "角色管理",
|
||||
"permissions": "权限目录",
|
||||
"teaching": "教学管理",
|
||||
"coursePlans": "课程计划",
|
||||
"electives": "选修课",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"message": "消息",
|
||||
"announcement": "公告",
|
||||
"homework": "作业",
|
||||
"grade": "成绩"
|
||||
"grade": "成绩",
|
||||
"diagnostic": "学情诊断"
|
||||
},
|
||||
"filter": {
|
||||
"all": "全部"
|
||||
|
||||
22
src/shared/i18n/messages/zh-CN/parent.json
Normal file
22
src/shared/i18n/messages/zh-CN/parent.json
Normal 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} 的详情"
|
||||
}
|
||||
}
|
||||
@@ -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 {# 次练习}}"
|
||||
}
|
||||
}
|
||||
|
||||
156
src/shared/i18n/messages/zh-CN/questions.json
Normal file
156
src/shared/i18n/messages/zh-CN/questions.json
Normal 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": "取消"
|
||||
}
|
||||
}
|
||||
318
src/shared/i18n/messages/zh-CN/rbac.json
Normal file
318
src/shared/i18n/messages/zh-CN/rbac.json
Normal 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": "角色状态已更新"
|
||||
}
|
||||
}
|
||||
@@ -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": "查看"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
"title": "我的学情诊断",
|
||||
"description": "查看你的知识点掌握度分析与诊断报告。"
|
||||
},
|
||||
"studyPath": {
|
||||
"title": "AI 学习路径",
|
||||
"description": "基于你的学情数据,AI 为你生成个性化学习路径,按步骤攻克薄弱知识点。",
|
||||
"noUser": "未找到用户",
|
||||
"noUserDesc": "请先登录学生账号以生成学习路径。"
|
||||
},
|
||||
"error": {
|
||||
"title": "出错了",
|
||||
"description": "发生了意外错误,请稍后重试。",
|
||||
|
||||
@@ -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": "保存中..."
|
||||
|
||||
@@ -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 位邀请码"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 默认 baseUrl(OpenAI 兼容端点) */
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
125
src/shared/lib/breached-password.ts
Normal file
125
src/shared/lib/breached-password.ts
Normal 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 API(Node.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 的 fetch(AbortController)。
|
||||
*/
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/shared/lib/export-utils.ts
Normal file
80
src/shared/lib/export-utils.ts
Normal 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`)
|
||||
}
|
||||
226
src/shared/lib/permission-bitmap.ts
Normal file
226
src/shared/lib/permission-bitmap.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 权限位图编解码(audit-P1-7)
|
||||
*
|
||||
* 目的:将 67 个权限点字符串数组(约 1.1KB JSON)压缩为 ~14 字符的 base36 字符串,
|
||||
* 使 JWT cookie 体积降至可接受范围(远低于 4KB 限制)。
|
||||
*
|
||||
* 编码方案:
|
||||
* - 权限点按 `PERMISSION_BITMAP_ORDER` 数组中的位置分配 bit(0-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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) }),
|
||||
}
|
||||
}
|
||||
71
src/shared/lib/rate-limit/index.ts
Normal file
71
src/shared/lib/rate-limit/index.ts
Normal 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)
|
||||
}
|
||||
78
src/shared/lib/rate-limit/memory-limiter.ts
Normal file
78
src/shared/lib/rate-limit/memory-limiter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
193
src/shared/lib/rate-limit/redis-limiter.ts
Normal file
193
src/shared/lib/rate-limit/redis-limiter.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/shared/lib/rate-limit/rules.ts
Normal file
67
src/shared/lib/rate-limit/rules.ts
Normal 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)) }),
|
||||
}
|
||||
}
|
||||
44
src/shared/lib/rate-limit/types.ts
Normal file
44
src/shared/lib/rate-limit/types.ts
Normal 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>
|
||||
}
|
||||
69
src/shared/lib/rate-limit/upstash-modules.d.ts
vendored
Normal file
69
src/shared/lib/rate-limit/upstash-modules.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
60
src/shared/lib/resolve-action-error.ts
Normal file
60
src/shared/lib/resolve-action-error.ts
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
83
src/shared/lib/route-permissions.ts
Normal file
83
src/shared/lib/route-permissions.ts
Normal 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 string),value 为所需权限点。
|
||||
*
|
||||
* 注意:此表仅控制"是否允许访问此路径",
|
||||
* 页面内的 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,
|
||||
}
|
||||
35
src/shared/lib/route-resolver.ts
Normal file
35
src/shared/lib/route-resolver.ts
Normal 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.ts(edge 运行时)中使用。
|
||||
*/
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
38
src/shared/lib/type-guards.ts
Normal file
38
src/shared/lib/type-guards.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
314
tests/integration/elective/elective-pure-functions.test.ts
Normal file
314
tests/integration/elective/elective-pure-functions.test.ts
Normal 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:00~15: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()
|
||||
})
|
||||
})
|
||||
2
tests/setup/empty-stub.ts
Normal file
2
tests/setup/empty-stub.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 测试环境空模块,用于替代 server-only 守卫
|
||||
export {}
|
||||
224
tmp_append_en.ps1
Normal file
224
tmp_append_en.ps1
Normal 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
0
tmp_merge_en.ps1
Normal file
0
update-md.cjs
Normal file
0
update-md.cjs
Normal file
Reference in New Issue
Block a user