refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y
V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复: - P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键 - P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student 头部 90% 重复代码,两个 Header 改为薄包装 - P2 单测:为 6 个纯函数添加 31 个单元测试 (tests/integration/dashboard/dashboard-utils.test.ts) - P2 a11y:admin 表格 caption、teacher/student 视图语义化标签 (header / section aria-label / aside aria-label) - 同步架构图 004/005
This commit is contained in:
@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import type { LessonPlanNode, RichTextBlockData } from "../types";
|
||||
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types";
|
||||
|
||||
interface BlockRendererProps {
|
||||
textbookId?: string;
|
||||
@@ -112,13 +112,13 @@ function SortableBlock({
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
data={node.data as ExerciseBlockData}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
data={node.data as TextStudyBlockData}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
|
||||
@@ -84,8 +84,8 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
: t("questionBank.inlineQuestion")}
|
||||
</span>
|
||||
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
|
||||
<button onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -97,7 +97,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
size="sm"
|
||||
onClick={() => setShowBank(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("questionBank.fromBank")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -105,7 +105,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
||||
size="sm"
|
||||
onClick={() => setShowInline(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("questionBank.inlineNew")}
|
||||
</Button>
|
||||
{data.publishedAssignmentId ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
@@ -37,7 +38,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
|
||||
function addAnnotation() {
|
||||
if (!selection) {
|
||||
alert(t("textStudy.selectFirst"));
|
||||
toast.error(t("textStudy.selectFirst"));
|
||||
return;
|
||||
}
|
||||
const ann: TextStudyAnnotation = {
|
||||
@@ -76,7 +77,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
onClick={addAnnotation}
|
||||
disabled={!selection}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("textStudy.addAnnotation")}
|
||||
</Button>
|
||||
{data.annotations.length > 0 && (
|
||||
@@ -100,8 +101,8 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
||||
}
|
||||
className="font-medium text-sm bg-transparent flex-1"
|
||||
/>
|
||||
<button onClick={() => removeAnnotation(ann.id)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
<button onClick={() => removeAnnotation(ann.id)} aria-label={t("action.delete")}>
|
||||
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X, Tag } from "lucide-react";
|
||||
@@ -27,9 +28,15 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
const [kpIds, setKpIds] = useState<string[]>([]);
|
||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||
|
||||
// 类型守卫:安全地将 string 收窄为联合类型
|
||||
const QUESTION_TYPES = ["single_choice", "text", "judgment"] as const;
|
||||
function isQuestionType(v: string): v is "single_choice" | "text" | "judgment" {
|
||||
return QUESTION_TYPES.includes(v as typeof QUESTION_TYPES[number]);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!text.trim()) {
|
||||
alert(t("questionBank.stemRequired"));
|
||||
toast.error(t("questionBank.stemRequired"));
|
||||
return;
|
||||
}
|
||||
const content: Record<string, unknown> =
|
||||
@@ -63,11 +70,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-label={t("questionBank.inlineTitle")}>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
@@ -75,7 +82,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as never)}
|
||||
onChange={(e) => {
|
||||
if (isQuestionType(e.target.value)) {
|
||||
setType(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
|
||||
@@ -120,7 +131,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
||||
setOptions(options.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
删除
|
||||
{t("action.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,11 +47,16 @@ export function KnowledgePointPicker({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("knowledgePoint.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -3,14 +3,51 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import { useLessonPlanContextSafe, useRoleConfig } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const roleConfig = useRoleConfig();
|
||||
|
||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleArchive() {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
@@ -36,27 +73,34 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
: t("list.neverSaved")}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const res = await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(t("confirm.archive"))) return;
|
||||
const res = await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) router.refresh();
|
||||
}}
|
||||
>
|
||||
{t("action.archive")}
|
||||
</Button>
|
||||
{roleConfig.canDuplicate && (
|
||||
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||
{t("action.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{roleConfig.canArchive && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{t("action.archive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("confirm.archiveTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirm.archive")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleArchive}>
|
||||
{t("action.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ export function LessonPlanEditor({
|
||||
<button
|
||||
key={blockType}
|
||||
onClick={() => {
|
||||
editor.addNode(blockType);
|
||||
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
|
||||
setShowAddMenu(false);
|
||||
}}
|
||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { getLessonPlansAction } from "../actions";
|
||||
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -15,12 +16,19 @@ interface Props {
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
|
||||
@@ -53,11 +53,16 @@ export function PublishHomeworkDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("publish.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-96"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("publish.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
|
||||
@@ -92,11 +92,16 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("questionBank.title")}
|
||||
className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">{t("questionBank.title")}</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} aria-label={t("action.close")}>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 border-b">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TemplatePicker() {
|
||||
: "border-outline-variant hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-title-md">{tpl.name}</div>
|
||||
<div className="font-title-md">{t(`template.names.${tpl.id}`)}</div>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{tpl.blocks.length === 0
|
||||
? t("template.blankHint")
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import type { LessonPlanVersion } from "../types";
|
||||
|
||||
@@ -46,13 +58,13 @@ export function VersionHistoryDrawer({
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
if (!confirm(t("version.revertConfirm", { versionNo }))) return;
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
alert(res.message);
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +99,27 @@ export function VersionHistoryDrawer({
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
{formatDateTime(v.createdAt)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => handleRevert(v.versionNo)}
|
||||
>
|
||||
{t("version.revert")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="mt-2">
|
||||
{t("version.revert")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("version.revertTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("version.revertConfirm", { versionNo: v.versionNo })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleRevert(v.versionNo)}>
|
||||
{t("action.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user