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:
SpecialX
2026-06-22 17:01:00 +08:00
parent 10c668f36a
commit e997abaf5e
41 changed files with 1811 additions and 516 deletions

View File

@@ -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

View File

@@ -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 ? (

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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")

View File

@@ -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>

View File

@@ -1,21 +1,26 @@
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";
// block 类型 → 中文默认标题
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
objective: "教学目标",
key_point: "教学重难点",
import: "导入",
new_teaching: "新授",
consolidation: "巩固练习",
summary: "课堂小结",
homework: "作业布置",
blackboard: "板书设计",
text_study: "文本研习",
exercise: "练习/作业",
rich_text: "自定义环节",
reflection: "教学反思",
// block 类型 → i18n 键(实际文本由 useTranslations("lessonPreparation").blockType.${type} 翻译)
// 保留此映射用于1) 新节点默认标题的 i18n 键查找 2) 类型守卫
export const BLOCK_TYPE_KEYS: Record<BlockType, string> = {
objective: "objective",
key_point: "key_point",
import: "import",
new_teaching: "new_teaching",
consolidation: "consolidation",
summary: "summary",
homework: "homework",
blackboard: "blackboard",
text_study: "text_study",
exercise: "exercise",
rich_text: "rich_text",
reflection: "reflection",
};
// 向后兼容:保留 BLOCK_TYPE_LABELS 名称但值为 i18n 键(实际翻译由组件层完成)
// @deprecated 使用 BLOCK_TYPE_KEYS 或 useTranslations("lessonPreparation").blockType.${type} 替代
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = BLOCK_TYPE_KEYS;
// 富文本类 block共享同一编辑组件
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
"objective",
@@ -100,8 +105,11 @@ export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
},
];
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = {
draft: "草稿",
published: "已发布",
archived: "已归档",
export const LESSON_PLAN_STATUS_KEYS: Record<string, string> = {
draft: "draft",
published: "published",
archived: "archived",
};
// @deprecated 使用 useTranslations("lessonPreparation").status.${key} 替代
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = LESSON_PLAN_STATUS_KEYS;

View File

@@ -1,7 +1,7 @@
import "server-only";
import { cache } from "react";
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
@@ -32,23 +32,53 @@ import type {
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
// ---- DataScope → 查询条件 ----
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
switch (scope.type) {
case "all":
return [];
case "owned":
return [eq(lessonPlans.creatorId, userId)];
case "class_taught":
case "grade_managed":
case "class_members":
case "children":
// 教师看自己创建的 + published 的
case "class_taught": {
// 教师:自己创建的 + published 且属于自己教授学科的
const own = eq(lessonPlans.creatorId, userId);
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const subjectFilter =
scope.subjectIds && scope.subjectIds.length > 0
? inArray(lessonPlans.subjectId, scope.subjectIds)
: sql<boolean>`true`;
return [
or(
eq(lessonPlans.creatorId, userId),
eq(lessonPlans.status, "published"),
own,
and(publishedFilter, subjectFilter),
)!,
];
}
case "grade_managed": {
// 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级
const own = eq(lessonPlans.creatorId, userId);
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const gradeFilter =
scope.gradeIds.length > 0
? inArray(lessonPlans.gradeId, scope.gradeIds)
: sql<boolean>`false`;
return [
or(
own,
and(publishedFilter, gradeFilter),
)!,
];
}
case "class_members": {
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
return [publishedFilter];
}
case "children": {
// 家长:仅查看 published 课案(同学生)
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
return [publishedFilter];
}
}
}

View File

@@ -9,7 +9,6 @@ import type {
LessonPlanEdge,
LessonPlanNode,
} from "../types";
import { BLOCK_TYPE_LABELS } from "../constants";
interface EditorState {
planId: string;
@@ -24,7 +23,7 @@ interface EditorState {
setPlanId: (planId: string) => void;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
updateNode: (id: string, patch: Partial<Block>) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
@@ -76,13 +75,13 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
selectedNodeId: null,
}),
addNode: (type, position) => {
addNode: (type, position, title) => {
const id = createId();
const nodeCount = get().doc.nodes.length;
const node: LessonPlanNode = {
id,
type,
title: BLOCK_TYPE_LABELS[type],
title: title ?? type, // 调用方应传入翻译后的标题fallback 为 type 键
data: defaultData(type),
order: nodeCount,
position: position ?? {

View File

@@ -0,0 +1,189 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { LessonPlanListItem, LessonPlanVersion } from "../types";
/**
* 备课模块数据服务接口P1-7
* 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。
* 组件不直接 import actions只通过此接口调用。
*/
export interface LessonPlanDataService {
/** 查询课案列表 */
getLessonPlans(params?: {
query?: string;
textbookId?: string;
chapterId?: string;
subjectId?: string;
status?: string;
}): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>;
/** 获取课案版本列表 */
getLessonPlanVersions(planId: string): Promise<{
success: boolean;
data?: { versions: LessonPlanVersion[] };
message?: string;
}>;
/** 回退到指定版本 */
revertLessonPlanVersion(params: {
planId: string;
versionNo: number;
}): Promise<{ success: boolean; message?: string }>;
/** 复制课案 */
duplicateLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
/** 删除/归档课案 */
deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
}
/**
* 角色配置P1-7决定该模块渲染哪些 Widget/子模块/操作。
* 新增角色只需新增配置项,不改组件代码。
*/
export interface LessonPlanRoleConfig {
/** 是否显示"新建课案"按钮 */
canCreate: boolean;
/** 是否显示"编辑"入口 */
canEdit: boolean;
/** 是否显示"发布为作业" */
canPublish: boolean;
/** 是否显示"复制" */
canDuplicate: boolean;
/** 是否显示"归档/删除" */
canArchive: boolean;
/** 是否显示"版本历史" */
canViewVersions: boolean;
/** 是否显示"AI 知识点建议" */
canUseAiSuggest: boolean;
/** 是否只读模式(学生/家长查看 published 课案) */
readOnly: boolean;
}
/** 默认教师配置 */
export const TEACHER_ROLE_CONFIG: LessonPlanRoleConfig = {
canCreate: true,
canEdit: true,
canPublish: true,
canDuplicate: true,
canArchive: true,
canViewVersions: true,
canUseAiSuggest: true,
readOnly: false,
};
/** 管理员配置(查看全校课案,不可编辑) */
export const ADMIN_ROLE_CONFIG: LessonPlanRoleConfig = {
canCreate: false,
canEdit: false,
canPublish: false,
canDuplicate: false,
canArchive: false,
canViewVersions: true,
canUseAiSuggest: false,
readOnly: true,
};
/** 学生配置(仅查看 published */
export const STUDENT_ROLE_CONFIG: LessonPlanRoleConfig = {
canCreate: false,
canEdit: false,
canPublish: false,
canDuplicate: false,
canArchive: false,
canViewVersions: false,
canUseAiSuggest: false,
readOnly: true,
};
/** 家长配置(查看孩子的 published 课案) */
export const PARENT_ROLE_CONFIG: LessonPlanRoleConfig = {
canCreate: false,
canEdit: false,
canPublish: false,
canDuplicate: false,
canArchive: false,
canViewVersions: false,
canUseAiSuggest: false,
readOnly: true,
};
/** 角色配置注册表 */
export const ROLE_CONFIGS: Record<string, LessonPlanRoleConfig> = {
admin: ADMIN_ROLE_CONFIG,
teacher: TEACHER_ROLE_CONFIG,
student: STUDENT_ROLE_CONFIG,
parent: PARENT_ROLE_CONFIG,
};
/** 监控埋点接口P2-4预留关键操作埋点 */
export interface LessonPlanTracker {
track(event: string, payload?: Record<string, unknown>): void;
}
/** 默认空实现埋点(生产环境可替换为真实埋点) */
export const noopTracker: LessonPlanTracker = {
track: () => {},
};
/** 备课模块上下文值 */
export interface LessonPlanContextValue {
/** 数据服务(抽象数据依赖) */
service: LessonPlanDataService;
/** 角色配置 */
roleConfig: LessonPlanRoleConfig;
/** 监控埋点 */
tracker: LessonPlanTracker;
}
const LessonPlanContext = createContext<LessonPlanContextValue | null>(null);
/** Provider 组件:注入数据服务、角色配置、埋点 */
export function LessonPlanProvider({
children,
service,
roleConfig,
tracker = noopTracker,
}: {
children: ReactNode;
service: LessonPlanDataService;
roleConfig: LessonPlanRoleConfig;
tracker?: LessonPlanTracker;
}) {
const value = useMemo<LessonPlanContextValue>(
() => ({ service, roleConfig, tracker }),
[service, roleConfig, tracker],
);
return <LessonPlanContext.Provider value={value}>{children}</LessonPlanContext.Provider>;
}
/** Hook获取备课模块上下文值若未在 Provider 内则返回 null不抛错 */
export function useLessonPlanContextSafe(): LessonPlanContextValue | null {
return useContext(LessonPlanContext);
}
/** Hook获取备课模块上下文值必须在 Provider 内使用) */
export function useLessonPlanContext(): LessonPlanContextValue {
const ctx = useContext(LessonPlanContext);
if (!ctx) {
throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用");
}
return ctx;
}
/** Hook获取角色配置若未在 Provider 内则返回教师默认配置) */
export function useRoleConfig(): LessonPlanRoleConfig {
const ctx = useContext(LessonPlanContext);
return ctx?.roleConfig ?? TEACHER_ROLE_CONFIG;
}
/** Hook获取数据服务 */
export function useLessonPlanService(): LessonPlanDataService {
return useLessonPlanContext().service;
}
/** Hook获取埋点 */
export function useLessonPlanTracker(): LessonPlanTracker {
return useLessonPlanContext().tracker;
}

View File

@@ -73,10 +73,17 @@ export async function publishLessonPlanHomework(
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
// 类型守卫:确保 inline 题目类型合法
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
const qt = item.inlineContent.type;
if (!validTypes.includes(qt as typeof validTypes[number])) {
throw new Error(`无效的题目类型: ${qt}`);
}
const questionType = qt as typeof validTypes[number];
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: item.inlineContent.type as never,
type: questionType,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},

View File

@@ -0,0 +1,50 @@
"use client";
import {
getLessonPlansAction,
getLessonPlanVersionsAction,
revertLessonPlanVersionAction,
duplicateLessonPlanAction,
deleteLessonPlanAction,
} from "../actions";
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
/**
* 默认数据服务实现:包装现有 Server Actions。
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
* 测试时可替换为 mock 实现。
*/
export function createDefaultDataService(): LessonPlanDataService {
return {
async getLessonPlans(params) {
const res = await getLessonPlansAction(params ?? {});
if (res.success && res.data) {
return { success: true, data: { items: res.data.items } };
}
return { success: false, message: res.message };
},
async getLessonPlanVersions(planId) {
const res = await getLessonPlanVersionsAction(planId);
if (res.success && res.data) {
return { success: true, data: { versions: res.data.versions } };
}
return { success: false, message: res.message };
},
async revertLessonPlanVersion(params) {
const res = await revertLessonPlanVersionAction(params);
return { success: res.success, message: res.message };
},
async duplicateLessonPlan(planId) {
const res = await duplicateLessonPlanAction(planId);
return { success: res.success, message: res.message };
},
async deleteLessonPlan(planId) {
const res = await deleteLessonPlanAction(planId);
return { success: res.success, message: res.message };
},
};
}