feat(lesson-preparation): major update with AI features, schedules, and new components
- Add actions-schedules.ts and data-access-schedules.ts for schedule management - Add AI differentiation, AI feedback, consistency check dialogs - Add attachment-picker, curriculum-heatmap, print-view, version-diff-view - Add lesson-plan-mobile-view and schedule-dialog components - Add lib: ai-differentiation, ai-feedback, auto-layout, consistency-check, curriculum-coverage, export, version-diff - Add history-slice hook for version history - Update existing components, hooks, providers, services, types - Add teacher lesson-plans heatmap and library pages
This commit is contained in:
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export default function HeatmapError() {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.title")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal file
22
src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function HeatmapLoading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[320px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[80px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[200px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal file
124
src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { getKnowledgePointsByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
|
||||||
|
import { CurriculumHeatmap } from "@/modules/lesson-preparation/components/curriculum-heatmap";
|
||||||
|
import type { PlanKpLink } from "@/modules/lesson-preparation/lib/curriculum-coverage";
|
||||||
|
import type { LessonPlanDocument } from "@/modules/lesson-preparation/types";
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-20 T4:教师端课标热力图页面。
|
||||||
|
*
|
||||||
|
* 展示教师所有课案对所选教材知识点的覆盖情况。
|
||||||
|
* 从教师所有课案中提取 knowledgePointIds,统计覆盖率。
|
||||||
|
*/
|
||||||
|
export default async function HeatmapPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
|
||||||
|
// 教师所有课案(含草稿和已发布)
|
||||||
|
const [plans, textbooks] = await Promise.all([
|
||||||
|
getLessonPlans({}, ctx.dataScope, ctx.userId),
|
||||||
|
getTextbooks(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 仅保留有教材的课案
|
||||||
|
const plansWithTextbook = plans.filter((p) => p.textbookId);
|
||||||
|
|
||||||
|
// 默认展示教师第一本教材(按使用频率)
|
||||||
|
const textbookCounts = new Map<string, number>();
|
||||||
|
for (const p of plansWithTextbook) {
|
||||||
|
if (p.textbookId) {
|
||||||
|
textbookCounts.set(p.textbookId, (textbookCounts.get(p.textbookId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const defaultTextbookId =
|
||||||
|
[...textbookCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? textbooks[0]?.id ?? "";
|
||||||
|
|
||||||
|
if (!defaultTextbookId || textbooks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.title")}
|
||||||
|
description={t("heatmap.selectTextbook")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该教材的所有知识点
|
||||||
|
const allKps = await getKnowledgePointsByTextbookId(defaultTextbookId);
|
||||||
|
|
||||||
|
// 提取该教材下所有课案的知识点关联
|
||||||
|
const planLinks: PlanKpLink[] = [];
|
||||||
|
const chapterNames: Record<string, string> = {};
|
||||||
|
for (const plan of plansWithTextbook.filter((p) => p.textbookId === defaultTextbookId)) {
|
||||||
|
try {
|
||||||
|
const fullPlan = await getLessonPlanById(plan.id, ctx.userId);
|
||||||
|
if (!fullPlan) continue;
|
||||||
|
const doc = fullPlan.content as LessonPlanDocument;
|
||||||
|
const kpSet = new Set<string>();
|
||||||
|
for (const node of doc.nodes) {
|
||||||
|
if (!isRecord(node.data)) continue;
|
||||||
|
const dataKps = (node.data as { knowledgePointIds?: unknown }).knowledgePointIds;
|
||||||
|
if (Array.isArray(dataKps)) {
|
||||||
|
for (const kp of dataKps) {
|
||||||
|
if (typeof kp === "string") kpSet.add(kp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kpSet.size > 0) {
|
||||||
|
planLinks.push({ planId: plan.id, knowledgePointIds: [...kpSet] });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 单个课案加载失败不阻塞整体
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节名映射(从 textbooks 模块获取)
|
||||||
|
// 此处简化:使用知识点自身的 chapterId,章节名从 textbooks data-access 获取
|
||||||
|
// 为避免 N+1 查询,此处仅用 chapterId 作为 key,章节名由前端未知章节兜底
|
||||||
|
// 完整实现可在 textbooks data-access 添加批量查询函数
|
||||||
|
for (const kp of allKps) {
|
||||||
|
if (kp.chapterId) {
|
||||||
|
chapterNames[kp.chapterId] = kp.chapterId; // 占位,实际应查询章节名
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6" aria-hidden="true" />
|
||||||
|
{t("heatmap.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("heatmap.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allKps.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("heatmap.noData")}
|
||||||
|
description={t("heatmap.description")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CurriculumHeatmap
|
||||||
|
allKps={allKps}
|
||||||
|
planLinks={planLinks}
|
||||||
|
chapterNames={chapterNames}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/library/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
|
||||||
|
export default function LibraryError() {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("library.title")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal file
17
src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function LibraryLoading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[320px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[160px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal file
106
src/app/(dashboard)/teacher/lesson-plans/library/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, Copy } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access";
|
||||||
|
import { duplicateLessonPlanFormAction } from "@/modules/lesson-preparation/actions";
|
||||||
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-12 R4:校内课案库。
|
||||||
|
*
|
||||||
|
* 展示本校其他教师分享的已发布课案,支持一键复制为自己的课案。
|
||||||
|
* 复用现有 getLessonPlans 数据访问(scope 已限定可见范围),
|
||||||
|
* 在页面层过滤掉当前用户自己创建的课案。
|
||||||
|
*/
|
||||||
|
export default async function LibraryPage(): Promise<JSX.Element> {
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
|
||||||
|
const allItems = await getLessonPlans(
|
||||||
|
{ status: "published" },
|
||||||
|
ctx.dataScope,
|
||||||
|
ctx.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 过滤掉当前用户自己创建的课案,仅展示他人分享的
|
||||||
|
const libraryItems = allItems.filter((item) => item.creatorId !== ctx.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<BookOpen className="h-6 w-6" aria-hidden="true" />
|
||||||
|
{t("library.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("library.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{libraryItems.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={BookOpen}
|
||||||
|
title={t("library.empty")}
|
||||||
|
description={t("library.description")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{libraryItems.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="border border-outline-variant rounded-lg p-4 bg-surface flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-base line-clamp-2">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/lesson-plans/${item.id}/edit`}
|
||||||
|
className="hover:text-primary"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1 text-xs text-on-surface-variant">
|
||||||
|
{item.subjectName && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
|
||||||
|
{item.subjectName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.gradeName && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest">
|
||||||
|
{item.gradeName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.textbookTitle && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-highest truncate max-w-[120px]">
|
||||||
|
{item.textbookTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-2">
|
||||||
|
{t("library.byCreator", {
|
||||||
|
creator: item.creatorName ?? t("library.noCreator"),
|
||||||
|
})}
|
||||||
|
{" · "}
|
||||||
|
{formatDateTime(item.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={duplicateLessonPlanFormAction}>
|
||||||
|
<input type="hidden" name="planId" value={item.id} />
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="w-full">
|
||||||
|
<Copy className="h-3.5 w-3.5 mr-1" aria-hidden="true" />
|
||||||
|
{t("library.fork")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,20 @@
|
|||||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
import { handleActionError } from "@/shared/lib/action-utils";
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
import { getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
|
||||||
import { suggestKnowledgePoints } from "./ai-suggest";
|
import { suggestKnowledgePoints } from "./ai-suggest";
|
||||||
import { suggestKnowledgePointsSchema } from "./schema";
|
import { suggestKnowledgePointsSchema } from "./schema";
|
||||||
import { translateFieldErrors } from "./lib/i18n-errors";
|
import { translateFieldErrors } from "./lib/i18n-errors";
|
||||||
|
import { generateLessonPlanFeedback, type AiFeedbackResult } from "./lib/ai-feedback";
|
||||||
|
import {
|
||||||
|
generateDifferentiationSuggestions,
|
||||||
|
checkCurriculumAlignment,
|
||||||
|
generateExplainableAssessment,
|
||||||
|
type DifferentiationSuggestion,
|
||||||
|
type CurriculumCheckItem,
|
||||||
|
type ExplainableAssessment,
|
||||||
|
} from "./lib/ai-differentiation";
|
||||||
|
import type { LessonPlanDocument } from "./types";
|
||||||
import type { ActionState } from "@/shared/types/action-state";
|
import type { ActionState } from "@/shared/types/action-state";
|
||||||
|
|
||||||
export async function suggestKnowledgePointsAction(input: {
|
export async function suggestKnowledgePointsAction(input: {
|
||||||
@@ -45,3 +56,99 @@ export async function suggestKnowledgePointsAction(input: {
|
|||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-17 A1/A2:AI 反馈闭环。
|
||||||
|
* 调用 AI 对课案文档生成结构化反馈(含解释性展示)。
|
||||||
|
*/
|
||||||
|
export async function generateLessonPlanFeedbackAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<AiFeedbackResult>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const result = await generateLessonPlanFeedback(doc);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A3:AI 差异化教学建议生成。
|
||||||
|
* 根据课案内容生成基础/提高/拓展三个层次的教学建议。
|
||||||
|
*/
|
||||||
|
export async function generateDifferentiationSuggestionsAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<{ items: DifferentiationSuggestion[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await generateDifferentiationSuggestions(doc);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A4:AI 课标实时核对。
|
||||||
|
* 检查课案是否覆盖教材课标要求。
|
||||||
|
*/
|
||||||
|
export async function checkCurriculumAlignmentAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
knowledgePoints: { id: string; name: string }[],
|
||||||
|
): Promise<ActionState<{ items: CurriculumCheckItem[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await checkCurriculumAlignment(doc, knowledgePoints);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A5:AI 可解释评估。
|
||||||
|
* 对课案中的练习节点给出可解释的评分依据。
|
||||||
|
*/
|
||||||
|
export async function generateExplainableAssessmentAction(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ActionState<{ items: ExplainableAssessment[] }>> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||||
|
requirePermission(Permissions.AI_CHAT),
|
||||||
|
]);
|
||||||
|
const items = await generateExplainableAssessment(doc);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A4 辅助:按教材 ID 获取知识点列表(仅 id + name),
|
||||||
|
* 供课标核对 Tab 按需加载,避免编辑页首屏负担。
|
||||||
|
*/
|
||||||
|
export async function getKnowledgePointsForAlignmentAction(
|
||||||
|
textbookId: string,
|
||||||
|
): Promise<ActionState<{ items: { id: string; name: string }[] }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const kps = await getKnowledgePointsByTextbookId(textbookId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: kps.map((kp) => ({ id: kp.id, name: kp.name })) },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
src/modules/lesson-preparation/actions-schedules.ts
Normal file
87
src/modules/lesson-preparation/actions-schedules.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* V5-7:课案-课时绑定 Server Actions
|
||||||
|
*/
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
getSchedulesByPlanId,
|
||||||
|
createSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
} from "./data-access-schedules";
|
||||||
|
import type { ActionState } from "@/shared/types/action-state";
|
||||||
|
import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard";
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils";
|
||||||
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
|
|
||||||
|
const createScheduleSchema = z.object({
|
||||||
|
planId: z.string().min(1, "error.planIdRequired"),
|
||||||
|
classId: z.string().min(1, "error.classIdRequired"),
|
||||||
|
scheduledDate: z.string().min(1, "error.dateRequired"),
|
||||||
|
period: z.number().int().min(1).max(12),
|
||||||
|
classScheduleId: z.string().optional(),
|
||||||
|
durationMin: z.number().int().min(5).max(180).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getLessonPlanSchedulesAction(
|
||||||
|
planId: string,
|
||||||
|
): Promise<ActionState<{ items: Awaited<ReturnType<typeof getSchedulesByPlanId>> }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||||
|
const items = await getSchedulesByPlanId(planId);
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLessonPlanScheduleAction(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<ActionState<{ schedule: Awaited<ReturnType<typeof createSchedule>> }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
|
const t = await getTranslations("lessonPreparation");
|
||||||
|
const parseResult = createScheduleSchema.safeParse(input);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: t("error.invalidInput"),
|
||||||
|
errors: Object.fromEntries(
|
||||||
|
Object.entries(parseResult.error.flatten().fieldErrors),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await getAuthContext();
|
||||||
|
if (!auth.userId) {
|
||||||
|
return { success: false, message: t("error.unauthorized") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await createSchedule({
|
||||||
|
...parseResult.data,
|
||||||
|
createdBy: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath("/teacher/lesson-plans/calendar");
|
||||||
|
return { success: true, data: { schedule } };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLessonPlanScheduleAction(
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||||
|
await deleteSchedule(scheduleId);
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
revalidatePath("/teacher/lesson-plans/calendar");
|
||||||
|
return { success: true, data: null };
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
import { Permissions } from "@/shared/types/permissions";
|
||||||
@@ -315,6 +316,19 @@ export async function duplicateLessonPlanAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V5-12 R4:表单兼容版本(从 formData 提取 planId),用于校内课案库一键复制
|
||||||
|
export async function duplicateLessonPlanFormAction(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<void> {
|
||||||
|
const planId = String(formData.get("planId") ?? "");
|
||||||
|
if (!planId) return;
|
||||||
|
const result = await duplicateLessonPlanAction(planId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
revalidatePath("/teacher/lesson-plans");
|
||||||
|
redirect(`/teacher/lesson-plans/${result.data.newPlanId}/edit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 模板列表 ----
|
// ---- 模板列表 ----
|
||||||
export async function getLessonPlanTemplatesAction(): Promise<
|
export async function getLessonPlanTemplatesAction(): Promise<
|
||||||
ActionState<{
|
ActionState<{
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Users,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
BookCheck,
|
||||||
|
ClipboardList,
|
||||||
|
GraduationCap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
generateDifferentiationSuggestionsAction,
|
||||||
|
checkCurriculumAlignmentAction,
|
||||||
|
generateExplainableAssessmentAction,
|
||||||
|
getKnowledgePointsForAlignmentAction,
|
||||||
|
} from "../actions-ai";
|
||||||
|
import type { LessonPlanDocument, DifferentiationLevel } from "../types";
|
||||||
|
import type {
|
||||||
|
DifferentiationSuggestion,
|
||||||
|
CurriculumCheckItem,
|
||||||
|
ExplainableAssessment,
|
||||||
|
} from "../lib/ai-differentiation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
/** 课案所属教材 ID(用于 A4 课标核对按需加载知识点) */
|
||||||
|
textbookId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = "differentiation" | "curriculum" | "assessment";
|
||||||
|
|
||||||
|
const TABS: TabKey[] = ["differentiation", "curriculum", "assessment"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-21 A3/A4/A5:AI 差异化对话框。
|
||||||
|
*
|
||||||
|
* 三个 Tab 分别承载:
|
||||||
|
* - A3 差异化教学建议(基础/提高/拓展)
|
||||||
|
* - A4 课标实时核对(按 textbookId 按需加载知识点)
|
||||||
|
* - A5 可解释评估(练习节点的评分依据)
|
||||||
|
*/
|
||||||
|
export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("differentiation");
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("aiDifferentiation.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[680px] max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<div className="flex border-b border-outline-variant" role="tablist">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-on-surface-variant hover:text-on-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`aiDifferentiation.tabs.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
|
{activeTab === "differentiation" && (
|
||||||
|
<DifferentiationTab doc={doc} t={t} />
|
||||||
|
)}
|
||||||
|
{activeTab === "curriculum" && (
|
||||||
|
<CurriculumTab doc={doc} textbookId={textbookId} t={t} />
|
||||||
|
)}
|
||||||
|
{activeTab === "assessment" && <AssessmentTab doc={doc} t={t} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-outline-variant flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("editor.consistencyClose")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A3:差异化教学建议 Tab */
|
||||||
|
function DifferentiationTab({
|
||||||
|
doc,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<DifferentiationSuggestion[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateDifferentiationSuggestionsAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
// 按层次排序:basic → intermediate → advanced
|
||||||
|
const order: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => order.indexOf(a.level) - order.indexOf(b.level),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sorted.map((item) => (
|
||||||
|
<section
|
||||||
|
key={item.level}
|
||||||
|
className="rounded border border-outline-variant p-3 bg-surface"
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
|
||||||
|
<Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
{t(`aiDifferentiation.level.${item.level}`)}
|
||||||
|
<span className="text-xs text-on-surface-variant">— {item.targetStudents}</span>
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1.5 text-sm text-on-surface-variant">
|
||||||
|
{item.suggestions.map((s, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-1.5">
|
||||||
|
<span className="text-primary mt-1">·</span>
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4:课标实时核对 Tab(按 textbookId 按需加载知识点) */
|
||||||
|
function CurriculumTab({
|
||||||
|
doc,
|
||||||
|
textbookId,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
textbookId?: string;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<CurriculumCheckItem[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
// 无教材 ID 时无法获取知识点
|
||||||
|
if (!textbookId) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(t("aiDifferentiation.noKnowledgePoints"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 先按需加载知识点列表
|
||||||
|
const kpRes = await getKnowledgePointsForAlignmentAction(textbookId);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!kpRes.success || !kpRes.data || kpRes.data.items.length === 0) {
|
||||||
|
setError(t("aiDifferentiation.noKnowledgePoints"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 再调用课标核对
|
||||||
|
const res = await checkCurriculumAlignmentAction(doc, kpRes.data.items);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, textbookId, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
const covered = items.filter((i) => i.covered).length;
|
||||||
|
const missed = items.length - covered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-1 text-primary">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.covered", { count: covered })}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-tertiary">
|
||||||
|
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
{t("aiDifferentiation.missed", { count: missed })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
{item.covered ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{item.requirement}</span>
|
||||||
|
</div>
|
||||||
|
{item.explanation && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5">{item.explanation}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A5:可解释评估 Tab */
|
||||||
|
function AssessmentTab({
|
||||||
|
doc,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [items, setItems] = useState<ExplainableAssessment[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateExplainableAssessmentAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("aiDifferentiation.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("aiDifferentiation.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label={t("aiDifferentiation.loading")} />;
|
||||||
|
if (error) return <ErrorBlock message={error} />;
|
||||||
|
if (items.length === 0) return <EmptyBlock label={t("aiDifferentiation.empty")} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium flex items-center gap-1.5">
|
||||||
|
<BookCheck className="w-3.5 h-3.5 text-primary" aria-hidden="true" />
|
||||||
|
<span>{item.conclusion}</span>
|
||||||
|
</div>
|
||||||
|
{item.rationale && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
|
||||||
|
<ClipboardList className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.rationale}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.suggestion && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 ml-5 flex items-start gap-1">
|
||||||
|
<GraduationCap className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.suggestion}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingBlock({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-on-surface-variant">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBlock({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyBlock({ label }: { label: string }) {
|
||||||
|
return <div className="text-sm text-on-surface-variant text-center py-6">{label}</div>;
|
||||||
|
}
|
||||||
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal file
184
src/modules/lesson-preparation/components/ai-feedback-dialog.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X, Sparkles, Loader2, AlertCircle, Lightbulb, CheckCircle2, Target, Users } from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { generateLessonPlanFeedbackAction } from "../actions-ai";
|
||||||
|
import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-17 A1/A2:AI 反馈闭环对话框(含解释性展示) */
|
||||||
|
export function AiFeedbackDialog({ doc, onClose }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [result, setResult] = useState<AiFeedbackResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await generateLessonPlanFeedbackAction(doc);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setResult(res.data);
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("feedback.loadFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("feedback.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [doc, t]);
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("feedback.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("feedback.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-on-surface-variant">{t("feedback.loading")}</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded border border-error/40 bg-error-container/30 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-error" aria-hidden="true" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : result ? (
|
||||||
|
<FeedbackContent result={result} t={t} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-outline-variant flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("editor.consistencyClose")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackContent({
|
||||||
|
result,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
result: AiFeedbackResult;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
// 按维度分组
|
||||||
|
const grouped: Record<AiFeedbackItem["category"], AiFeedbackItem[]> = {
|
||||||
|
strengths: [],
|
||||||
|
improvements: [],
|
||||||
|
alignment: [],
|
||||||
|
differentiation: [],
|
||||||
|
};
|
||||||
|
for (const item of result.items) {
|
||||||
|
grouped[item.category].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 摘要 + 评分 */}
|
||||||
|
<div className="flex items-center justify-between rounded border border-outline-variant p-3 bg-surface-container-low">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("feedback.summary")}</p>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3">
|
||||||
|
<div className="text-2xl font-bold text-primary">{result.overallScore}</div>
|
||||||
|
<div className="text-xs text-on-surface-variant">{t("feedback.score")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 各维度反馈 */}
|
||||||
|
{(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => {
|
||||||
|
const items = grouped[cat];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={cat}>
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1.5 mb-2">
|
||||||
|
<CategoryIcon category={cat} />
|
||||||
|
{t(`feedback.category.${cat}`)}
|
||||||
|
<span className="text-xs text-on-surface-variant">({items.length})</span>
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="rounded border border-outline-variant p-2.5 bg-surface"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{item.title}</div>
|
||||||
|
{/* A2:解释性展示 — reason 字段说明 AI 判断依据 */}
|
||||||
|
{item.reason && (
|
||||||
|
<div className="text-xs text-on-surface-variant mt-1 flex items-start gap-1">
|
||||||
|
<Lightbulb className="w-3 h-3 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<span>{item.reason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<div className="text-sm text-on-surface-variant text-center py-6">
|
||||||
|
{t("feedback.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryIcon({ category }: { category: AiFeedbackItem["category"] }) {
|
||||||
|
const icon = {
|
||||||
|
strengths: <CheckCircle2 className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
|
||||||
|
improvements: <AlertCircle className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />,
|
||||||
|
alignment: <Target className="w-3.5 h-3.5 text-secondary" aria-hidden="true" />,
|
||||||
|
differentiation: <Users className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
|
||||||
|
}[category];
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
281
src/modules/lesson-preparation/components/attachment-picker.tsx
Normal file
281
src/modules/lesson-preparation/components/attachment-picker.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X, Upload, FileText, Image as ImageIcon, Music, Video, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { useFileUpload } from "@/modules/files/hooks/use-file-upload";
|
||||||
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||||
|
import type { LessonPlanAttachmentOption } from "../providers/lesson-plan-provider";
|
||||||
|
import type { RichTextAttachment } from "../types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
planId: string;
|
||||||
|
blockId?: string;
|
||||||
|
/** 当前节点已嵌入的附件(用于高亮已选) */
|
||||||
|
selectedIds?: string[];
|
||||||
|
/** 选择附件后的回调(嵌入到富文本) */
|
||||||
|
onSelect: (attachment: RichTextAttachment) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-5:根据 MIME 类型推断渲染种类(供调用方使用) */
|
||||||
|
export function inferAttachmentKind(
|
||||||
|
mimeType: string | undefined,
|
||||||
|
): RichTextAttachment["kind"] {
|
||||||
|
if (!mimeType) return "file";
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("audio/")) return "audio";
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindIcon(kind: RichTextAttachment["kind"]) {
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return <ImageIcon className="w-4 h-4" aria-hidden="true" />;
|
||||||
|
case "audio":
|
||||||
|
return <Music className="w-4 h-4" aria-hidden="true" />;
|
||||||
|
case "video":
|
||||||
|
return <Video className="w-4 h-4" aria-hidden="true" />;
|
||||||
|
default:
|
||||||
|
return <FileText className="w-4 h-4" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-5:素材库 Picker
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 上传新文件(复用 use-file-upload hook,调用 /api/upload)
|
||||||
|
* 2. 上传成功后调用 service.createLessonPlanAttachment 落库到 lessonPlanAttachments 表
|
||||||
|
* 3. 列出当前课案已上传的所有附件(service.getLessonPlanAttachments)
|
||||||
|
* 4. 点击附件 → onSelect 回调,由调用方决定如何嵌入
|
||||||
|
*
|
||||||
|
* 不直接 import actions,通过 LessonPlanContext 注入的 service 调用。
|
||||||
|
*/
|
||||||
|
export function AttachmentPicker({
|
||||||
|
planId,
|
||||||
|
blockId,
|
||||||
|
selectedIds = [],
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
|
const [items, setItems] = useState<LessonPlanAttachmentOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// V5-5:复用项目已有 use-file-upload hook
|
||||||
|
const { inputRef, handleFiles, tasks } = useFileUpload({
|
||||||
|
targetType: "lesson_plan",
|
||||||
|
targetId: planId,
|
||||||
|
multiple: true,
|
||||||
|
onUploaded: async (result) => {
|
||||||
|
if (!service) return;
|
||||||
|
// 上传成功后创建附件记录
|
||||||
|
const res = await service.createLessonPlanAttachment({
|
||||||
|
planId,
|
||||||
|
blockId,
|
||||||
|
fileId: result.id,
|
||||||
|
displayName: result.originalName,
|
||||||
|
attachmentType: "material",
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("attachment.createSuccess"));
|
||||||
|
// 刷新附件列表
|
||||||
|
void loadAttachments();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// V5-5:加载附件列表
|
||||||
|
const loadAttachments = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await service.getLessonPlanAttachments(planId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setItems(res.data.items);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AttachmentPicker] load failed", e);
|
||||||
|
toast.error(t("attachment.empty"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [service, planId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAttachments();
|
||||||
|
}, [loadAttachments]);
|
||||||
|
|
||||||
|
// V5-5:ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// V5-5:删除附件
|
||||||
|
async function handleDelete(attachmentId: string) {
|
||||||
|
if (!service) return;
|
||||||
|
try {
|
||||||
|
const res = await service.deleteLessonPlanAttachment(attachmentId);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("attachment.deleteSuccess"));
|
||||||
|
void loadAttachments();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AttachmentPicker] delete failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-5:选择附件嵌入富文本
|
||||||
|
function handleSelect(item: LessonPlanAttachmentOption) {
|
||||||
|
const attachment: RichTextAttachment = {
|
||||||
|
attachmentId: item.id,
|
||||||
|
fileId: item.fileId,
|
||||||
|
displayName: item.displayName,
|
||||||
|
kind: inferAttachmentKind(item.mimeType),
|
||||||
|
url: item.url ?? `/api/files/${item.fileId}`,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
};
|
||||||
|
onSelect(attachment);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("attachment.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 className="font-title-md">{t("attachment.title")}</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1 space-y-4">
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<div className="border-2 border-dashed border-outline-variant rounded p-4 text-center">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-1" /> {t("attachment.add")}
|
||||||
|
</Button>
|
||||||
|
{/* 上传任务进度 */}
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1 text-xs text-left">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={`${task.file.name}-${task.file.size}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{task.file.name}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
task.status === "error"
|
||||||
|
? "text-error"
|
||||||
|
: task.status === "success"
|
||||||
|
? "text-primary"
|
||||||
|
: "text-on-surface-variant"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{task.status === "error"
|
||||||
|
? t("attachment.uploadFailed")
|
||||||
|
: task.status === "success"
|
||||||
|
? t("attachment.uploadSuccess")
|
||||||
|
: `${task.progress}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 已上传附件列表 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-2">
|
||||||
|
{t("attachment.libraryLabel")}
|
||||||
|
</label>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-on-surface-variant text-center py-4">
|
||||||
|
{t("version.loading")}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-sm text-on-surface-variant text-center py-4">
|
||||||
|
{t("attachment.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = selectedIds.includes(item.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-center gap-2 border rounded p-2 ${
|
||||||
|
isSelected ? "border-primary bg-primary/5" : "border-outline-variant"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-on-surface-variant">
|
||||||
|
{kindIcon(inferAttachmentKind(item.mimeType))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex-1 text-left text-sm hover:underline"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
{item.displayName}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-on-surface-variant">
|
||||||
|
{t(`attachment.type.${item.attachmentType}`)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="text-error hover:bg-error/10 p-1 rounded"
|
||||||
|
onClick={() => void handleDelete(item.id)}
|
||||||
|
aria-label={t("attachment.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t flex justify-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("action.close")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Tag } from "lucide-react";
|
import { Tag, Eye, Pencil } from "lucide-react";
|
||||||
import type { BlackboardBlockData } from "../../types";
|
import type { BlackboardBlockData } from "../../types";
|
||||||
import { isBlackboardLayout } from "../../lib/type-guards";
|
import { isBlackboardLayout } from "../../lib/type-guards";
|
||||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||||
@@ -17,47 +17,98 @@ interface Props {
|
|||||||
|
|
||||||
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
|
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-14 F4:板书可视化工具。
|
||||||
|
*
|
||||||
|
* 在原有纯文本编辑基础上增加轻量级可视化预览(不引入新库):
|
||||||
|
* - structure(结构式):按行解析缩进,渲染为带连接线的层级树
|
||||||
|
* - mindmap(思维导图):第一行为中心,其余为分支节点
|
||||||
|
* - text(文字式):等宽字体直接展示
|
||||||
|
*
|
||||||
|
* 编辑/预览模式切换,避免双栏占满侧边面板。
|
||||||
|
*/
|
||||||
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
|
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||||
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-on-surface-variant">
|
<div className="text-xs text-on-surface-variant">
|
||||||
{t("blackboard.hint")}
|
{t("blackboard.hint")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium block mb-1">
|
<div className="flex items-center gap-2">
|
||||||
{t("blackboard.layoutLabel")}
|
<div className="flex-1">
|
||||||
</label>
|
<label className="text-xs font-medium block mb-1">
|
||||||
<select
|
{t("blackboard.layoutLabel")}
|
||||||
value={data.layout}
|
</label>
|
||||||
onChange={(e) => {
|
<select
|
||||||
const value = e.target.value;
|
value={data.layout}
|
||||||
if (isBlackboardLayout(value)) {
|
onChange={(e) => {
|
||||||
onUpdate({ ...data, layout: value });
|
const value = e.target.value;
|
||||||
}
|
if (isBlackboardLayout(value)) {
|
||||||
}}
|
onUpdate({ ...data, layout: value });
|
||||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
}
|
||||||
>
|
}}
|
||||||
{LAYOUTS.map((l) => (
|
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
|
||||||
<option key={l} value={l}>
|
>
|
||||||
{t(`blackboard.layout.${l}`)}
|
{LAYOUTS.map((l) => (
|
||||||
</option>
|
<option key={l} value={l}>
|
||||||
))}
|
{t(`blackboard.layout.${l}`)}
|
||||||
</select>
|
</option>
|
||||||
</div>
|
))}
|
||||||
<div>
|
</select>
|
||||||
<label className="text-xs font-medium block mb-1">
|
</div>
|
||||||
{t("blackboard.contentLabel")}
|
{/* 编辑/预览切换 */}
|
||||||
</label>
|
<div className="flex border border-outline-variant rounded overflow-hidden self-end">
|
||||||
<textarea
|
<button
|
||||||
value={data.content}
|
type="button"
|
||||||
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
|
onClick={() => setPreviewMode(false)}
|
||||||
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
|
aria-pressed={!previewMode}
|
||||||
placeholder={t("blackboard.contentPlaceholder")}
|
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
|
||||||
/>
|
!previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
|
||||||
|
}`}
|
||||||
|
title={t("blackboard.editMode")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||||||
|
{t("blackboard.editMode")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewMode(true)}
|
||||||
|
aria-pressed={previewMode}
|
||||||
|
className={`px-2 py-1 text-xs inline-flex items-center gap-1 ${
|
||||||
|
previewMode ? "bg-primary text-on-primary" : "bg-surface text-on-surface-variant"
|
||||||
|
}`}
|
||||||
|
title={t("blackboard.previewMode")}
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||||
|
{t("blackboard.previewMode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区:编辑模式 or 预览模式 */}
|
||||||
|
{previewMode ? (
|
||||||
|
<BlackboardPreview content={data.content} layout={data.layout} t={t} />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1">
|
||||||
|
{t("blackboard.contentLabel")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={data.content}
|
||||||
|
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
|
||||||
|
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
|
||||||
|
placeholder={t("blackboard.contentPlaceholder")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1">
|
||||||
|
{t("blackboard.editHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{data.knowledgePointIds.length > 0 && (
|
{data.knowledgePointIds.length > 0 && (
|
||||||
<span className="text-xs text-on-surface-variant">
|
<span className="text-xs text-on-surface-variant">
|
||||||
@@ -86,3 +137,116 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 板书可视化预览。纯 CSS + 文本解析,不引入新库。
|
||||||
|
*
|
||||||
|
* - structure:按行解析前导空格/Tab 缩进,渲染为带竖线的层级树
|
||||||
|
* - mindmap:第一行为中心节点,其余为放射分支
|
||||||
|
* - text:等宽字体直接展示
|
||||||
|
*/
|
||||||
|
function BlackboardPreview({
|
||||||
|
content,
|
||||||
|
layout,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
layout: BlackboardBlockData["layout"];
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const parsed = useMemo(() => parseContent(content, layout), [content, layout]);
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-on-surface-variant text-center py-6 border border-dashed border-outline-variant rounded">
|
||||||
|
{t("blackboard.previewEmpty")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "text") {
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className="text-sm font-mono whitespace-pre-wrap border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "mindmap") {
|
||||||
|
const center = parsed[0]?.text ?? "";
|
||||||
|
const branches = parsed.slice(1);
|
||||||
|
return (
|
||||||
|
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
|
||||||
|
{/* 中心节点 */}
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<span className="px-3 py-1 rounded-full bg-primary text-on-primary text-sm font-medium text-center max-w-[80%]">
|
||||||
|
{center || t("blackboard.untitled")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 分支节点 */}
|
||||||
|
{branches.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{branches.map((node, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
style={{ paddingLeft: `${node.level * 12}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-primary" aria-hidden="true">└</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-surface border border-outline-variant">
|
||||||
|
{node.text}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// structure:层级树
|
||||||
|
return (
|
||||||
|
<div className="border border-outline-variant rounded p-3 bg-surface-container-low min-h-[120px]">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{parsed.map((node, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="flex items-start gap-1 text-sm"
|
||||||
|
style={{ paddingLeft: `${node.level * 14}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-on-surface-variant mt-0.5" aria-hidden="true">
|
||||||
|
{node.level === 0 ? "●" : "├"}
|
||||||
|
</span>
|
||||||
|
<span className={node.level === 0 ? "font-medium" : ""}>
|
||||||
|
{node.text}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析文本为节点列表(按缩进识别层级) */
|
||||||
|
interface ParsedNode {
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContent(
|
||||||
|
content: string,
|
||||||
|
_layout: BlackboardBlockData["layout"],
|
||||||
|
): ParsedNode[] {
|
||||||
|
const lines = content.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.trim());
|
||||||
|
return lines.map((line) => {
|
||||||
|
// 前导空格(每 2 空格或 1 Tab 算一级)
|
||||||
|
const leadingMatch = line.match(/^[\t ]*/);
|
||||||
|
const leading = leadingMatch ? leadingMatch[0] : "";
|
||||||
|
const tabs = (leading.match(/\t/g) ?? []).length;
|
||||||
|
const spaces = (leading.match(/ /g) ?? []).length;
|
||||||
|
const level = tabs + spaces;
|
||||||
|
return { level, text: line.trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
planId={planId}
|
planId={planId}
|
||||||
blockId={blockId}
|
blockId={blockId}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
|
items={data.items}
|
||||||
onClose={() => setShowPublish(false)}
|
onClose={() => setShowPublish(false)}
|
||||||
onPublished={() => router.refresh()}
|
onPublished={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,18 +3,24 @@
|
|||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { RichTextBlockData } from "../../types";
|
import type { RichTextBlockData, RichTextAttachment } from "../../types";
|
||||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||||
|
import { AttachmentPicker } from "../attachment-picker";
|
||||||
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
|
||||||
import { Tag } from "lucide-react";
|
import { Tag, Paperclip, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: RichTextBlockData;
|
data: RichTextBlockData;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
|
/** V5-5:当前课案 ID(用于附件库 picker) */
|
||||||
|
planId?: string;
|
||||||
|
/** V5-5:当前 block ID(用于附件关联) */
|
||||||
|
blockId?: string;
|
||||||
onUpdate: (data: RichTextBlockData) => void;
|
onUpdate: (data: RichTextBlockData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +29,8 @@ export function RichTextBlock({
|
|||||||
hint,
|
hint,
|
||||||
textbookId,
|
textbookId,
|
||||||
chapterId,
|
chapterId,
|
||||||
|
planId,
|
||||||
|
blockId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
@@ -30,6 +38,14 @@ export function RichTextBlock({
|
|||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
|
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
|
||||||
|
// V5-5:集成 Tiptap Image 扩展,支持图片直接嵌入富文本
|
||||||
|
Image.configure({
|
||||||
|
inline: false,
|
||||||
|
allowBase64: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "rich-text-image max-w-full h-auto rounded",
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
content: data.html,
|
content: data.html,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
@@ -52,6 +68,33 @@ export function RichTextBlock({
|
|||||||
}, [data.html, editor]);
|
}, [data.html, editor]);
|
||||||
|
|
||||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||||
|
const [showAttachmentPicker, setShowAttachmentPicker] = useState(false); // V5-5
|
||||||
|
|
||||||
|
// V5-5:从素材库选择附件后,插入到富文本(图片用 Image 命令,其余追加到 attachments 列表)
|
||||||
|
function handleSelectAttachment(attachment: RichTextAttachment) {
|
||||||
|
const currentAttachments = data.attachments ?? [];
|
||||||
|
if (attachment.kind === "image" && editor) {
|
||||||
|
// 图片直接插入富文本
|
||||||
|
editor.commands.setImage({ src: attachment.url, alt: attachment.displayName });
|
||||||
|
}
|
||||||
|
// 所有附件(含图片)都记录到 attachments 列表,便于后续管理与素材库复用
|
||||||
|
onUpdate({
|
||||||
|
...data,
|
||||||
|
attachments: [...currentAttachments, attachment],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-5:移除已嵌入的附件
|
||||||
|
function handleRemoveAttachment(attachmentId: string) {
|
||||||
|
const currentAttachments = data.attachments ?? [];
|
||||||
|
onUpdate({
|
||||||
|
...data,
|
||||||
|
attachments: currentAttachments.filter((a) => a.attachmentId !== attachmentId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-5:渲染附件区块
|
||||||
|
const attachments = data.attachments ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -69,7 +112,79 @@ export function RichTextBlock({
|
|||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
{t("knowledgePoint.annotate")}
|
{t("knowledgePoint.annotate")}
|
||||||
</button>
|
</button>
|
||||||
|
{/* V5-5:素材库入口 */}
|
||||||
|
{planId && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAttachmentPicker(true)}
|
||||||
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{t("attachment.insert")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* V5-5:已嵌入附件列表(非图片附件在此展示,图片已在富文本中渲染) */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="mt-2 px-3 space-y-1">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t("attachment.embeddedCount", { count: attachments.length })}
|
||||||
|
</div>
|
||||||
|
{attachments
|
||||||
|
.filter((a) => a.kind !== "image")
|
||||||
|
.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.attachmentId}
|
||||||
|
className="flex items-center gap-2 text-xs border border-outline-variant rounded p-1"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={a.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-1 hover:underline truncate"
|
||||||
|
>
|
||||||
|
{a.displayName}
|
||||||
|
</a>
|
||||||
|
<span className="text-on-surface-variant">{a.kind}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveAttachment(a.attachmentId)}
|
||||||
|
className="text-error hover:bg-error/10 p-0.5 rounded"
|
||||||
|
aria-label={t("attachment.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* V5-5:图片附件单独展示缩略图(即使已在富文本中内联,此处也展示便于管理) */}
|
||||||
|
{attachments.some((a) => a.kind === "image") && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachments
|
||||||
|
.filter((a) => a.kind === "image")
|
||||||
|
.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.attachmentId}
|
||||||
|
className="relative group border border-outline-variant rounded"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={a.url}
|
||||||
|
alt={a.displayName}
|
||||||
|
className="w-16 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveAttachment(a.attachmentId)}
|
||||||
|
className="absolute top-0 right-0 bg-error text-on-error rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("attachment.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-2.5 h-2.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showKpPicker && (
|
{showKpPicker && (
|
||||||
<LessonPlanErrorBoundary>
|
<LessonPlanErrorBoundary>
|
||||||
<KnowledgePointPicker
|
<KnowledgePointPicker
|
||||||
@@ -81,6 +196,18 @@ export function RichTextBlock({
|
|||||||
/>
|
/>
|
||||||
</LessonPlanErrorBoundary>
|
</LessonPlanErrorBoundary>
|
||||||
)}
|
)}
|
||||||
|
{/* V5-5:素材库 picker */}
|
||||||
|
{showAttachmentPicker && planId && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<AttachmentPicker
|
||||||
|
planId={planId}
|
||||||
|
blockId={blockId}
|
||||||
|
selectedIds={attachments.map((a) => a.attachmentId)}
|
||||||
|
onSelect={handleSelectAttachment}
|
||||||
|
onClose={() => setShowAttachmentPicker(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
import { checkConsistency, hasConsistencyWarnings, type ConsistencyIssue } from "../lib/consistency-check";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-19 T3:教学评一致性校验对话框。
|
||||||
|
*
|
||||||
|
* 以纯函数 `checkConsistency` 计算结果,仅做 UI 展示。
|
||||||
|
* 不修改文档,不阻断保存。
|
||||||
|
*/
|
||||||
|
export function ConsistencyCheckDialog({ doc, onClose }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
|
||||||
|
const result = useMemo(() => checkConsistency(doc), [doc]);
|
||||||
|
const hasWarnings = hasConsistencyWarnings(result);
|
||||||
|
|
||||||
|
// ESC 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("consistency.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[520px] max-h-[80vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("consistency.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("editor.consistencyClose")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto space-y-4">
|
||||||
|
{/* 顶部统计 */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="rounded border border-outline-variant p-2">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t("editor.consistencyObjectiveCount", { count: result.objectiveCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-outline-variant p-2">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t("editor.consistencyExerciseCount", { count: result.exerciseCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-outline-variant p-2">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t("consistency.coverage", {
|
||||||
|
covered: result.coveredObjectiveCount,
|
||||||
|
total: result.objectiveCount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 一致性分数 */}
|
||||||
|
<div className="flex items-center justify-between rounded border border-outline-variant p-3">
|
||||||
|
<span className="text-sm font-medium">{t("consistency.score", { score: result.score })}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded font-medium",
|
||||||
|
result.score >= 80
|
||||||
|
? "bg-primary-container text-on-primary-container"
|
||||||
|
: result.score >= 50
|
||||||
|
? "bg-secondary-container text-on-secondary-container"
|
||||||
|
: "bg-error-container text-on-error-container",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 问题列表 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-on-surface-variant mb-2">
|
||||||
|
{hasWarnings ? t("consistency.title") : t("consistency.noIssues")}
|
||||||
|
</div>
|
||||||
|
{hasWarnings ? (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{result.issues.map((issue, idx) => (
|
||||||
|
<IssueItem key={`${issue.code}-${idx}`} issue={issue} t={t} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-on-surface-variant p-3 rounded border border-outline-variant bg-surface-container-low">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
{t("consistency.noIssues")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-outline-variant flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-sm px-3 py-1.5 rounded border border-outline-variant hover:bg-surface-container-low"
|
||||||
|
>
|
||||||
|
{t("editor.consistencyClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueItem({
|
||||||
|
issue,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
issue: ConsistencyIssue;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const isWarning = issue.severity === "warning";
|
||||||
|
const message = t(`consistency.code.${issue.code}`, {
|
||||||
|
title: issue.params?.title ?? issue.nodeTitle ?? "",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 px-2 py-1.5 rounded border text-sm",
|
||||||
|
isWarning
|
||||||
|
? "border-error/40 bg-error-container/30"
|
||||||
|
: "border-outline-variant bg-surface-container-low",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
className={cn("w-3.5 h-3.5 mt-0.5 flex-shrink-0", isWarning ? "text-error" : "text-on-surface-variant")}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{message}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/modules/lesson-preparation/components/curriculum-heatmap.tsx
Normal file
162
src/modules/lesson-preparation/components/curriculum-heatmap.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
computeCurriculumCoverage,
|
||||||
|
getHeatLevel,
|
||||||
|
type PlanKpLink,
|
||||||
|
} from "../lib/curriculum-coverage";
|
||||||
|
import type { KnowledgePoint } from "@/modules/textbooks/types";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allKps: KnowledgePoint[];
|
||||||
|
planLinks: PlanKpLink[];
|
||||||
|
/** 章节 ID → 章节名映射 */
|
||||||
|
chapterNames: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-20 T4:课标覆盖度热力图组件 */
|
||||||
|
export function CurriculumHeatmap({ allKps, planLinks, chapterNames }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
|
||||||
|
const result = useMemo(
|
||||||
|
() => computeCurriculumCoverage(allKps, planLinks),
|
||||||
|
[allKps, planLinks],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 顶部统计条 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatCard
|
||||||
|
label={t("heatmap.totalKps")}
|
||||||
|
value={result.totalKps}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("heatmap.coveredKps")}
|
||||||
|
value={result.coveredKps}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("heatmap.coverageRate")}
|
||||||
|
value={`${result.overallCoverageRate}%`}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 教学盲点警告 */}
|
||||||
|
{result.blindSpots.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded border border-error/40 bg-error-container/30">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-error mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-error">
|
||||||
|
{t("heatmap.blindSpotTitle", { count: result.blindSpots.length })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant mt-1">
|
||||||
|
{result.blindSpots.slice(0, 5).map((b) => b.kpName).join("、")}
|
||||||
|
{result.blindSpots.length > 5 && t("heatmap.andMore")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按章节展开的热力图 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.chapters.map((ch) => {
|
||||||
|
const level = getHeatLevel(ch.coverageRate);
|
||||||
|
const chapterName = chapterNames[ch.chapterId] ?? t("heatmap.unknownChapter");
|
||||||
|
return (
|
||||||
|
<section key={ch.chapterId} className="border border-outline-variant rounded-lg overflow-hidden">
|
||||||
|
{/* 章节头部带热力色块 */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn("inline-block w-3 h-3 rounded-sm", heatColorClass(level))}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm">{chapterName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-on-surface-variant">
|
||||||
|
{t("heatmap.chapterCoverage", {
|
||||||
|
covered: ch.coveredKps,
|
||||||
|
total: ch.totalKps,
|
||||||
|
rate: ch.coverageRate,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 知识点列表 */}
|
||||||
|
<ul className="divide-y divide-outline-variant">
|
||||||
|
{ch.kps.map((kp) => {
|
||||||
|
const kpLevel = getHeatLevel(kp.planCount === 0 ? 0 : Math.min(100, kp.planCount * 33));
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={kp.kpId}
|
||||||
|
className="flex items-center justify-between px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn("inline-block w-2 h-2 rounded-full flex-shrink-0", heatColorClass(kpLevel))}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{kp.kpName}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-1.5 py-0.5 rounded flex-shrink-0",
|
||||||
|
kp.isBlindSpot
|
||||||
|
? "bg-error-container/40 text-error"
|
||||||
|
: "bg-surface-container-highest text-on-surface-variant",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{kp.isBlindSpot
|
||||||
|
? t("heatmap.notCovered")
|
||||||
|
: t("heatmap.planCount", { count: kp.planCount })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded border p-3 text-center",
|
||||||
|
highlight
|
||||||
|
? "border-primary/40 bg-primary-container/20"
|
||||||
|
: "border-outline-variant bg-surface",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-xl font-bold">{value}</div>
|
||||||
|
<div className="text-xs text-on-surface-variant mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 热力等级 → Tailwind 颜色类 */
|
||||||
|
function heatColorClass(level: 0 | 1 | 2 | 3 | 4): string {
|
||||||
|
return [
|
||||||
|
"bg-error/60",
|
||||||
|
"bg-tertiary/40",
|
||||||
|
"bg-tertiary/70",
|
||||||
|
"bg-primary/60",
|
||||||
|
"bg-primary",
|
||||||
|
][level];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { NodeEditor } from "./node-editor";
|
import { NodeEditor } from "./node-editor";
|
||||||
@@ -24,8 +24,14 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Plus, Save, History, Book, FileText, Send, Undo2 } from "lucide-react";
|
import { Plus, Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { PrintView } from "./print-view";
|
||||||
|
import { ScheduleDialog } from "./schedule-dialog";
|
||||||
|
import { ConsistencyCheckDialog } from "./consistency-check-dialog";
|
||||||
|
import { AiFeedbackDialog } from "./ai-feedback-dialog";
|
||||||
|
import { AiDifferentiationDialog } from "./ai-differentiation-dialog";
|
||||||
|
import type { LessonPlan, LessonPlanDocument } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -75,6 +81,11 @@ export function LessonPlanEditor({
|
|||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
const [showVersions, setShowVersions] = useState(false);
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
|
const [showPrint, setShowPrint] = useState(false); // V5-4:打印视图
|
||||||
|
const [showSchedule, setShowSchedule] = useState(false); // V5-7:安排课时对话框
|
||||||
|
const [showConsistency, setShowConsistency] = useState(false); // V5-19:一致性校验对话框
|
||||||
|
const [showAiFeedback, setShowAiFeedback] = useState(false); // V5-17:AI 反馈对话框
|
||||||
|
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21:AI 差异化对话框
|
||||||
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
|
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -90,9 +101,11 @@ export function LessonPlanEditor({
|
|||||||
|
|
||||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||||
// V3 修复:完全通过 service 调用,不直接 import actions
|
// V3 修复:完全通过 service 调用,不直接 import actions
|
||||||
|
// V5-1 修复:保存失败显示 toast + 设置 saveError;断网时不触发保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor.isDirty) return;
|
if (!editor.isDirty) return;
|
||||||
if (!service) return;
|
if (!service) return;
|
||||||
|
if (!editor.isOnline) return; // V5-1:断网期间不触发保存请求
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
autoSaveTimer.current = setTimeout(async () => {
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
const state = useLessonPlanEditor.getState();
|
const state = useLessonPlanEditor.getState();
|
||||||
@@ -103,9 +116,18 @@ export function LessonPlanEditor({
|
|||||||
title: state.title,
|
title: state.title,
|
||||||
content: state.doc,
|
content: state.doc,
|
||||||
});
|
});
|
||||||
if (res.success) state.markSaved();
|
if (res.success) {
|
||||||
|
// V5-1:从失败恢复到成功时提示
|
||||||
|
if (state.saveError) toast.success(t("status.recovered"));
|
||||||
|
state.markSaved();
|
||||||
|
} else {
|
||||||
|
state.setSaveError(true);
|
||||||
|
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[LessonPlanEditor] auto-save failed", e);
|
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||||
|
state.setSaveError(true);
|
||||||
|
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
|
||||||
} finally {
|
} finally {
|
||||||
state.setSaving(false);
|
state.setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -113,7 +135,98 @@ export function LessonPlanEditor({
|
|||||||
return () => {
|
return () => {
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
};
|
};
|
||||||
}, [editor.isDirty, editor.doc, planId, service]);
|
}, [editor.isDirty, editor.doc, planId, service, editor.isOnline, t]);
|
||||||
|
|
||||||
|
// V5-1:监听网络在线/离线状态
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOnline() {
|
||||||
|
useLessonPlanEditor.getState().setOnline(true);
|
||||||
|
toast.success(t("status.backOnline"));
|
||||||
|
}
|
||||||
|
function handleOffline() {
|
||||||
|
useLessonPlanEditor.getState().setOnline(false);
|
||||||
|
toast.error(t("status.offline"), { description: t("status.offlineHint") });
|
||||||
|
}
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// V5-1:手动重试保存(saveError 时显示按钮)
|
||||||
|
const handleRetrySave = useCallback(async () => {
|
||||||
|
if (!service) return;
|
||||||
|
const state = useLessonPlanEditor.getState();
|
||||||
|
state.setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await service.updateLessonPlan({
|
||||||
|
planId: state.planId,
|
||||||
|
title: state.title,
|
||||||
|
content: state.doc,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("status.recovered"));
|
||||||
|
state.markSaved();
|
||||||
|
} else {
|
||||||
|
toast.error(t("status.saveFailed"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LessonPlanEditor] retry save failed", e);
|
||||||
|
toast.error(t("status.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
state.setSaving(false);
|
||||||
|
}
|
||||||
|
}, [service, t]);
|
||||||
|
|
||||||
|
// V5-2:撤销/重做快捷键(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y)
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const isMod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!isMod) return;
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key === "z" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const state = useLessonPlanEditor.getState();
|
||||||
|
if (state.canUndo()) state.undo();
|
||||||
|
} else if ((key === "z" && e.shiftKey) || key === "y") {
|
||||||
|
e.preventDefault();
|
||||||
|
const state = useLessonPlanEditor.getState();
|
||||||
|
if (state.canRedo()) state.redo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// V5-2:撤销/重做按钮处理(追踪 canUndo/canRedo 以驱动 disabled 状态)
|
||||||
|
const canUndo = editor.canUndo();
|
||||||
|
const canRedo = editor.canRedo();
|
||||||
|
const handleUndo = useCallback(() => useLessonPlanEditor.getState().undo(), []);
|
||||||
|
const handleRedo = useCallback(() => useLessonPlanEditor.getState().redo(), []);
|
||||||
|
|
||||||
|
// V5-4:构造打印用的 LessonPlan 对象(编辑器仅持有 planId/title/doc,其余字段填默认值)
|
||||||
|
const printablePlan: LessonPlan = useMemo(() => {
|
||||||
|
const doc: LessonPlanDocument = editor.doc;
|
||||||
|
return {
|
||||||
|
id: planId,
|
||||||
|
title: editor.title,
|
||||||
|
textbookId: textbookId ?? null,
|
||||||
|
chapterId: chapterId ?? null,
|
||||||
|
coursePlanItemId: null,
|
||||||
|
subjectId: null,
|
||||||
|
gradeId: null,
|
||||||
|
templateId: null,
|
||||||
|
templateName: null,
|
||||||
|
content: doc,
|
||||||
|
status: planStatus,
|
||||||
|
creatorId: "",
|
||||||
|
lastSavedAt: editor.lastSavedAt ? new Date(editor.lastSavedAt).toISOString() : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}, [planId, editor.title, editor.doc, editor.lastSavedAt, textbookId, chapterId, planStatus]);
|
||||||
|
|
||||||
// 定时自动版本(30min)
|
// 定时自动版本(30min)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,6 +379,25 @@ export function LessonPlanEditor({
|
|||||||
? t("status.unsaved")
|
? t("status.unsaved")
|
||||||
: t("status.saved")}
|
: t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
|
{/* V5-1:保存失败/离线提示与重试按钮 */}
|
||||||
|
{editor.saveError && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRetrySave}
|
||||||
|
disabled={editor.isSaving}
|
||||||
|
className="text-error border-error"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3 h-3 mr-1" />
|
||||||
|
{t("status.retrySave")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!editor.isOnline && (
|
||||||
|
<span className="text-xs text-error inline-flex items-center gap-1">
|
||||||
|
<WifiOff className="w-3 h-3" />
|
||||||
|
{t("status.offlineBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -273,9 +405,77 @@ export function LessonPlanEditor({
|
|||||||
>
|
>
|
||||||
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
|
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* V5-2:撤销/重做按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
aria-label={t("action.undo")}
|
||||||
|
title={t("action.undoShortcut")}
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
aria-label={t("action.redo")}
|
||||||
|
title={t("action.redoShortcut")}
|
||||||
|
>
|
||||||
|
<Redo className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||||
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* V5-4:导出/打印按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPrint(true)}
|
||||||
|
aria-label={t("export.title")}
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4 mr-1" /> {t("export.button")}
|
||||||
|
</Button>
|
||||||
|
{/* V5-7:安排课时按钮 */}
|
||||||
|
{classes && classes.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSchedule(true)}
|
||||||
|
aria-label={t("action.scheduleLesson")}
|
||||||
|
>
|
||||||
|
<CalendarClock className="w-4 h-4 mr-1" /> {t("action.scheduleLesson")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* V5-19 T3:一致性校验按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowConsistency(true)}
|
||||||
|
aria-label={t("editor.consistencyOpen")}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="w-4 h-4 mr-1" /> {t("editor.consistencyOpen")}
|
||||||
|
</Button>
|
||||||
|
{/* V5-17 A1/A2:AI 反馈按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAiFeedback(true)}
|
||||||
|
aria-label={t("feedback.open")}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-1" /> {t("feedback.open")}
|
||||||
|
</Button>
|
||||||
|
{/* V5-21 A3/A4/A5:AI 差异化与课标核对按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAiDifferentiation(true)}
|
||||||
|
aria-label={t("aiDifferentiation.open")}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 mr-1" /> {t("aiDifferentiation.open")}
|
||||||
|
</Button>
|
||||||
{/* 发布/撤回发布按钮(P0-1 修复)*/}
|
{/* 发布/撤回发布按钮(P0-1 修复)*/}
|
||||||
{planStatus === "published" ? (
|
{planStatus === "published" ? (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -362,6 +562,7 @@ export function LessonPlanEditor({
|
|||||||
{editor.selectedNodeId && (
|
{editor.selectedNodeId && (
|
||||||
<div className="w-[420px] flex-shrink-0">
|
<div className="w-[420px] flex-shrink-0">
|
||||||
<NodeEditPanel
|
<NodeEditPanel
|
||||||
|
planId={planId}
|
||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
chapterId={chapterId}
|
chapterId={chapterId}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
@@ -377,8 +578,63 @@ export function LessonPlanEditor({
|
|||||||
onClose={() => setShowVersions(false)}
|
onClose={() => setShowVersions(false)}
|
||||||
planId={planId}
|
planId={planId}
|
||||||
onReverted={handleReverted}
|
onReverted={handleReverted}
|
||||||
|
currentDoc={editor.doc}
|
||||||
/>
|
/>
|
||||||
</LessonPlanErrorBoundary>
|
</LessonPlanErrorBoundary>
|
||||||
|
|
||||||
|
{/* V5-4:导出/打印视图 */}
|
||||||
|
{showPrint && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<PrintView
|
||||||
|
plan={printablePlan}
|
||||||
|
textbookTitle={textbookTitle}
|
||||||
|
chapterTitle={chapterTitle}
|
||||||
|
onClose={() => setShowPrint(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* V5-7:安排课时对话框 */}
|
||||||
|
{showSchedule && classes && classes.length > 0 && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<ScheduleDialog
|
||||||
|
planId={planId}
|
||||||
|
classes={classes}
|
||||||
|
onClose={() => setShowSchedule(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* V5-19 T3:一致性校验对话框 */}
|
||||||
|
{showConsistency && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<ConsistencyCheckDialog
|
||||||
|
doc={editor.doc}
|
||||||
|
onClose={() => setShowConsistency(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* V5-17 A1/A2:AI 反馈对话框 */}
|
||||||
|
{showAiFeedback && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<AiFeedbackDialog
|
||||||
|
doc={editor.doc}
|
||||||
|
onClose={() => setShowAiFeedback(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* V5-21 A3/A4/A5:AI 差异化与课标核对对话框 */}
|
||||||
|
{showAiDifferentiation && (
|
||||||
|
<LessonPlanErrorBoundary>
|
||||||
|
<AiDifferentiationDialog
|
||||||
|
doc={editor.doc}
|
||||||
|
textbookId={textbookId}
|
||||||
|
onClose={() => setShowAiDifferentiation(false)}
|
||||||
|
/>
|
||||||
|
</LessonPlanErrorBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Book } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
LessonPlanDocument,
|
||||||
|
LessonPlanNode,
|
||||||
|
TeachingStage,
|
||||||
|
} from "../types";
|
||||||
|
import { TEACHING_STAGE_KEYS } from "../types";
|
||||||
|
import { getNodeColor, getNodeSummary } from "../lib/node-summary";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
textbookTitle?: string;
|
||||||
|
chapterTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-13 P3:移动端只读视图。
|
||||||
|
*
|
||||||
|
* 在小屏设备上以线性卡片列表替代 React Flow 画布,
|
||||||
|
* 按教学阶段(V5-15)分组展示节点,提升可读性。
|
||||||
|
* 仅展示,无编辑交互。
|
||||||
|
*/
|
||||||
|
export function LessonPlanMobileView({ doc, textbookTitle, chapterTitle }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
|
||||||
|
// 教学节点按 stage 分组(未归类的归入"其他")
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const teachingNodes = doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
|
);
|
||||||
|
const groups: Record<string, LessonPlanNode[]> = {};
|
||||||
|
const unstaged: LessonPlanNode[] = [];
|
||||||
|
for (const n of teachingNodes) {
|
||||||
|
if (n.stage) {
|
||||||
|
const key = n.stage as TeachingStage;
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(n);
|
||||||
|
} else {
|
||||||
|
unstaged.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { groups, unstaged };
|
||||||
|
}, [doc.nodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-y-auto bg-surface-container-low">
|
||||||
|
{/* 顶部信息条 */}
|
||||||
|
{(textbookTitle || chapterTitle) && (
|
||||||
|
<div className="sticky top-0 z-10 bg-surface/95 backdrop-blur border-b border-outline-variant px-4 py-2 text-sm">
|
||||||
|
{textbookTitle && (
|
||||||
|
<div className="flex items-center gap-1 text-on-surface-variant">
|
||||||
|
<Book className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
<span className="font-medium">{textbookTitle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chapterTitle && (
|
||||||
|
<div className="text-on-surface-variant text-xs mt-0.5">{chapterTitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 px-3 py-3 space-y-4">
|
||||||
|
{/* 按阶段分组渲染 */}
|
||||||
|
{TEACHING_STAGE_KEYS.map((stage) => {
|
||||||
|
const nodes = grouped.groups[stage];
|
||||||
|
if (!nodes || nodes.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={stage} className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium text-on-surface-variant px-1">
|
||||||
|
{t(`editor.stage.${stage}`)}
|
||||||
|
</h3>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<MobileNodeCard key={n.id} node={n} t={t} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 未归类节点 */}
|
||||||
|
{grouped.unstaged.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
{TEACHING_STAGE_KEYS.some((s) => grouped.groups[s]?.length > 0) && (
|
||||||
|
<h3 className="text-xs font-medium text-on-surface-variant px-1">
|
||||||
|
{t("editor.stageNone")}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{grouped.unstaged.map((n) => (
|
||||||
|
<MobileNodeCard key={n.id} node={n} t={t} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileNodeCard({
|
||||||
|
node,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
node: LessonPlanNode;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const color = getNodeColor(node.type);
|
||||||
|
const summary = getNodeSummary(node, (key, values) => t(key, values));
|
||||||
|
const diff = node.differentiation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg border border-outline-variant bg-surface p-3 shadow-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full mt-1.5 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<h4 className="font-medium text-sm text-on-surface truncate flex-1">
|
||||||
|
{node.title || node.type}
|
||||||
|
</h4>
|
||||||
|
{diff && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0",
|
||||||
|
diff === "basic"
|
||||||
|
? "bg-primary-container text-on-primary-container"
|
||||||
|
: diff === "intermediate"
|
||||||
|
? "bg-secondary-container text-on-secondary-container"
|
||||||
|
: "bg-tertiary-container text-on-tertiary-container",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`editor.differentiation.${diff}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1 line-clamp-3">
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { LessonNode } from "./nodes/lesson-node";
|
|||||||
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||||
import { getNodeColor } from "../lib/node-summary";
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
|
import { useMediaQuery } from "@/shared/hooks/use-media-query";
|
||||||
|
import { LessonPlanMobileView } from "./lesson-plan-mobile-view";
|
||||||
import type { LessonPlanDocument } from "../types";
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
@@ -39,6 +41,8 @@ interface Props {
|
|||||||
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
|
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
// V5-13 P3:小屏设备使用线性移动视图替代画布
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
|
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
|
||||||
const rfEdges = useMemo(
|
const rfEdges = useMemo(
|
||||||
@@ -65,6 +69,13 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
|
|||||||
});
|
});
|
||||||
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
|
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
|
||||||
|
|
||||||
|
// V5-13 P3:移动端渲染线性视图
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative">
|
<div className="h-full w-full relative">
|
||||||
{/* 顶部信息条 */}
|
{/* 顶部信息条 */}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Trash2, X } from "lucide-react";
|
import { Trash2, X } from "lucide-react";
|
||||||
import { getNodeColor } from "../lib/node-summary";
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
|
import type { TeachingStage, DifferentiationLevel } from "../types";
|
||||||
|
import { TEACHING_STAGE_KEYS, DIFFERENTIATION_LEVEL_KEYS } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P0-11 修复:AI 内容生成器 slot 类型。
|
* P0-11 修复:AI 内容生成器 slot 类型。
|
||||||
@@ -27,11 +29,13 @@ interface Props {
|
|||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
classes?: { id: string; name: string }[];
|
classes?: { id: string; name: string }[];
|
||||||
|
/** V5-5:当前课案 ID(透传给 RichTextBlock 用于素材库 picker) */
|
||||||
|
planId?: string;
|
||||||
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
|
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
|
||||||
aiContentGenerator?: AiContentGeneratorSlot;
|
aiContentGenerator?: AiContentGeneratorSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
|
export function NodeEditPanel({ textbookId, chapterId, classes, planId, aiContentGenerator }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const tAi = useTranslations("ai");
|
const tAi = useTranslations("ai");
|
||||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
|
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
|
||||||
@@ -156,6 +160,55 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* V5-15 T1 + V5-18 W6:节点属性条(教学阶段 + 差异化标记) */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface-container-low">
|
||||||
|
{/* 教学阶段 */}
|
||||||
|
<label className="text-xs text-on-surface-variant flex items-center gap-1">
|
||||||
|
{t("editor.stageLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={lessonNode.stage ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
updateNode(lessonNode.id, {
|
||||||
|
stage: v === "" ? undefined : (v as TeachingStage),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
|
||||||
|
aria-label={t("editor.stageLabel")}
|
||||||
|
>
|
||||||
|
<option value="">{t("editor.stageNone")}</option>
|
||||||
|
{TEACHING_STAGE_KEYS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`editor.stage.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 差异化标记 */}
|
||||||
|
<label className="text-xs text-on-surface-variant flex items-center gap-1 ml-2">
|
||||||
|
{t("editor.differentiationLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={lessonNode.differentiation ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
updateNode(lessonNode.id, {
|
||||||
|
differentiation: v === "" ? undefined : (v as DifferentiationLevel),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
|
||||||
|
aria-label={t("editor.differentiationLabel")}
|
||||||
|
>
|
||||||
|
<option value="">{t("editor.differentiationNone")}</option>
|
||||||
|
{DIFFERENTIATION_LEVEL_KEYS.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{t(`editor.differentiation.${d}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
|
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<LessonPlanErrorBoundary>
|
<LessonPlanErrorBoundary>
|
||||||
@@ -166,6 +219,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
|
|||||||
textbookId={textbookId}
|
textbookId={textbookId}
|
||||||
chapterId={chapterId}
|
chapterId={chapterId}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
|
planId={planId}
|
||||||
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
|
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
|
||||||
/>
|
/>
|
||||||
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
|
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
type Connection,
|
type Connection,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
Panel,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { LayoutGrid } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||||
import { LessonNode } from "./nodes/lesson-node";
|
import { LessonNode } from "./nodes/lesson-node";
|
||||||
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||||
@@ -43,8 +46,14 @@ export function NodeEditor({}: Props) {
|
|||||||
setEdges,
|
setEdges,
|
||||||
addAnchor,
|
addAnchor,
|
||||||
addNode,
|
addNode,
|
||||||
|
autoLayout,
|
||||||
} = useLessonPlanEditor();
|
} = useLessonPlanEditor();
|
||||||
|
|
||||||
|
// V5-8:自动布局按钮
|
||||||
|
const handleAutoLayout = useCallback(() => {
|
||||||
|
autoLayout("TB");
|
||||||
|
}, [autoLayout]);
|
||||||
|
|
||||||
// P1-1:构建可锚定的教学节点列表(排除正文节点)
|
// P1-1:构建可锚定的教学节点列表(排除正文节点)
|
||||||
const anchorableNodes = useMemo(
|
const anchorableNodes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -233,6 +242,10 @@ export function NodeEditor({}: Props) {
|
|||||||
}}
|
}}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="bg-surface-container-low"
|
className="bg-surface-container-low"
|
||||||
|
onlyRenderVisibleElements
|
||||||
|
minZoom={0.2}
|
||||||
|
maxZoom={2.5}
|
||||||
|
elevateNodesOnSelect={false}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
@@ -241,6 +254,20 @@ export function NodeEditor({}: Props) {
|
|||||||
color="#ccc"
|
color="#ccc"
|
||||||
/>
|
/>
|
||||||
<Controls className="!bg-surface !border-outline-variant" />
|
<Controls className="!bg-surface !border-outline-variant" />
|
||||||
|
{/* V5-8:自动布局按钮 */}
|
||||||
|
<Panel position="top-right" className="!m-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAutoLayout}
|
||||||
|
disabled={doc.nodes.length === 0}
|
||||||
|
title={t("editor.autoLayoutHint")}
|
||||||
|
aria-label={t("editor.autoLayout")}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4 mr-1" />
|
||||||
|
{t("editor.autoLayout")}
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
<MiniMap
|
<MiniMap
|
||||||
className="!bg-surface !border-outline-variant"
|
className="!bg-surface !border-outline-variant"
|
||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
|
|||||||
174
src/modules/lesson-preparation/components/print-view.tsx
Normal file
174
src/modules/lesson-preparation/components/print-view.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { X, Printer } from "lucide-react";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { flattenLessonPlanForPrint, type ExportVariant, type PrintableLessonPlan } from "../lib/export";
|
||||||
|
import type { LessonPlan } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: LessonPlan;
|
||||||
|
textbookTitle?: string;
|
||||||
|
chapterTitle?: string;
|
||||||
|
teacherName?: string;
|
||||||
|
className?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-4:打印视图组件
|
||||||
|
*
|
||||||
|
* 渲染扁平化后的 PrintableLessonPlan,提供"详细版/简洁版"切换。
|
||||||
|
* 点击"打印"按钮调用 window.print(),通过浏览器原生能力保存为 PDF。
|
||||||
|
* 使用 print: CSS 媒体查询隐藏工具栏,仅打印教学环节内容。
|
||||||
|
*/
|
||||||
|
export function PrintView({
|
||||||
|
plan,
|
||||||
|
textbookTitle,
|
||||||
|
chapterTitle,
|
||||||
|
teacherName,
|
||||||
|
className,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [variant, setVariant] = useState<ExportVariant>("detailed");
|
||||||
|
|
||||||
|
// V5-4:esc 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const printable = useMemo<PrintableLessonPlan>(
|
||||||
|
() =>
|
||||||
|
flattenLessonPlanForPrint(
|
||||||
|
plan,
|
||||||
|
{ textbookTitle, chapterTitle, teacherName, className },
|
||||||
|
variant,
|
||||||
|
),
|
||||||
|
[plan, textbookTitle, chapterTitle, teacherName, className, variant],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePrint() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center print:static print:bg-white print:p-0">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("export.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[800px] max-h-[90vh] flex flex-col print:static print:w-full print:max-h-none print:rounded-none print:shadow-none"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
{/* 工具栏:print 时隐藏 */}
|
||||||
|
<div className="flex justify-between items-center p-4 border-b print:hidden">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="font-title-md">{t("export.title")}</h3>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
className={`px-2 py-0.5 rounded ${variant === "detailed" ? "bg-primary text-on-primary" : "bg-surface-container-high"}`}
|
||||||
|
onClick={() => setVariant("detailed")}
|
||||||
|
aria-pressed={variant === "detailed"}
|
||||||
|
>
|
||||||
|
{t("export.variantDetailed")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-2 py-0.5 rounded ${variant === "concise" ? "bg-primary text-on-primary" : "bg-surface-container-high"}`}
|
||||||
|
onClick={() => setVariant("concise")}
|
||||||
|
aria-pressed={variant === "concise"}
|
||||||
|
>
|
||||||
|
{t("export.variantConcise")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="w-4 h-4 mr-1" /> {t("export.print")}
|
||||||
|
</Button>
|
||||||
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 打印内容主体 */}
|
||||||
|
<div className="p-8 overflow-y-auto flex-1 print:overflow-visible">
|
||||||
|
{/* 页眉 */}
|
||||||
|
<div className="border-b-2 border-on-surface pb-2 mb-4">
|
||||||
|
<h1 className="text-2xl font-bold text-center">
|
||||||
|
{printable.meta.planTitle}
|
||||||
|
</h1>
|
||||||
|
<div className="flex justify-between text-sm text-on-surface-variant mt-2">
|
||||||
|
<span>
|
||||||
|
{printable.meta.textbookTitle && `${printable.meta.textbookTitle}`}
|
||||||
|
{printable.meta.chapterTitle && ` · ${printable.meta.chapterTitle}`}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{printable.meta.teacherName && `${t("export.teacher")}: ${printable.meta.teacherName}`}
|
||||||
|
{printable.meta.className && ` · ${t("export.class")}: ${printable.meta.className}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant mt-1 text-right">
|
||||||
|
{t("export.totalDuration", { count: printable.meta.totalDurationMin })}
|
||||||
|
{printable.meta.lastSavedAt &&
|
||||||
|
` · ${t("export.lastSavedAt")}: ${new Date(printable.meta.lastSavedAt).toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 课文正文 */}
|
||||||
|
{printable.textbookContent && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold border-l-4 border-primary pl-2 mb-2">
|
||||||
|
{t("editor.textbookContent")}
|
||||||
|
</h2>
|
||||||
|
<div className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||||
|
{printable.textbookContent}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 教学环节列表 */}
|
||||||
|
{printable.sections.length === 0 ? (
|
||||||
|
<p className="text-center text-on-surface-variant py-8">
|
||||||
|
{t("export.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
printable.sections.map((section, idx) => (
|
||||||
|
<section key={`${section.type}-${idx}`} className="mb-4 break-inside-avoid">
|
||||||
|
<h2 className="text-lg font-semibold border-l-4 border-primary pl-2 mb-2">
|
||||||
|
{idx + 1}. {section.title}
|
||||||
|
</h2>
|
||||||
|
<div className="pl-4 space-y-1">
|
||||||
|
{section.lines.length === 0 ? (
|
||||||
|
<p className="text-sm text-on-surface-variant italic">
|
||||||
|
{t("export.emptySection")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
section.lines.map((line, i) => (
|
||||||
|
<p key={i} className="text-sm leading-relaxed">
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<div className="border-t border-outline-variant mt-8 pt-2 text-xs text-on-surface-variant text-center print:fixed print:bottom-2 print:left-0 print:right-0">
|
||||||
|
{t("export.footerHint", { variant: t(`export.variant${variant === "detailed" ? "Detailed" : "Concise"}`) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
import { X } from "lucide-react";
|
import { X, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react";
|
||||||
|
import type { ExerciseItem } from "../types";
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
classes: { id: string; name: string }[];
|
classes: { id: string; name: string }[];
|
||||||
|
/** V5-3:题目列表(从 exercise-block 传入,用于预览步骤)*/
|
||||||
|
items: ExerciseItem[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPublished: () => void;
|
onPublished: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Step = "select" | "preview" | "confirm";
|
||||||
|
|
||||||
|
/** V5-3:从 inline 题目内容中安全提取题干预览文本 */
|
||||||
|
function extractStemPreview(content: unknown): string {
|
||||||
|
if (!isRecord(content)) return "";
|
||||||
|
const stem = content.stem;
|
||||||
|
if (typeof stem === "string") return stem.slice(0, 80);
|
||||||
|
const text = content.text;
|
||||||
|
if (typeof text === "string") return text.slice(0, 80);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-3:题型 i18n 键映射 */
|
||||||
|
function questionTypeKey(type: string): string {
|
||||||
|
const known = ["single_choice", "multiple_choice", "true_false", "short_answer", "essay", "text", "judgment"];
|
||||||
|
return known.includes(type) ? `questionBank.type.${type}` : "questionBank.type.single_choice";
|
||||||
|
}
|
||||||
|
|
||||||
export function PublishHomeworkDialog({
|
export function PublishHomeworkDialog({
|
||||||
planId,
|
planId,
|
||||||
blockId,
|
blockId,
|
||||||
classes,
|
classes,
|
||||||
|
items,
|
||||||
onClose,
|
onClose,
|
||||||
onPublished,
|
onPublished,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -26,6 +49,7 @@ export function PublishHomeworkDialog({
|
|||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
const service = ctx?.service ?? null;
|
const service = ctx?.service ?? null;
|
||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
|
const [step, setStep] = useState<Step>("select");
|
||||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||||
const [availableAt, setAvailableAt] = useState("");
|
const [availableAt, setAvailableAt] = useState("");
|
||||||
const [dueAt, setDueAt] = useState("");
|
const [dueAt, setDueAt] = useState("");
|
||||||
@@ -41,12 +65,44 @@ export function PublishHomeworkDialog({
|
|||||||
return () => document.removeEventListener("keydown", handleEsc);
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
async function handlePublish() {
|
// V5-3:计算总分
|
||||||
if (!service) return;
|
const totalScore = useMemo(
|
||||||
|
() => items.reduce((sum, it) => sum + (it.score ?? 0), 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// V5-3:选中的班级数
|
||||||
|
const selectedClassCount = selectedClasses.length;
|
||||||
|
|
||||||
|
function handleSelectClass(classId: string) {
|
||||||
|
setSelectedClasses((prev) =>
|
||||||
|
prev.includes(classId) ? prev.filter((x) => x !== classId) : [...prev, classId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNextFromSelect() {
|
||||||
if (selectedClasses.length === 0) {
|
if (selectedClasses.length === 0) {
|
||||||
setError(t("publish.selectClass"));
|
setError(t("publish.selectClass"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError(null);
|
||||||
|
setStep("preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNextFromPreview() {
|
||||||
|
setStep("confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToSelect() {
|
||||||
|
setStep("select");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToPreview() {
|
||||||
|
setStep("preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublish() {
|
||||||
|
if (!service) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -76,78 +132,190 @@ export function PublishHomeworkDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V5-3:步骤指示器文案
|
||||||
|
const stepLabel =
|
||||||
|
step === "select"
|
||||||
|
? t("publish.step1")
|
||||||
|
: step === "preview"
|
||||||
|
? t("publish.step2")
|
||||||
|
: t("publish.step3");
|
||||||
|
const stepNumber = step === "select" ? 1 : step === "preview" ? 2 : 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("publish.title")}
|
aria-label={t("publish.title")}
|
||||||
className="bg-surface rounded-lg shadow-xl w-96"
|
className="bg-surface rounded-lg shadow-xl w-[640px] max-h-[90vh] flex flex-col"
|
||||||
>
|
>
|
||||||
<FocusTrap className="contents">
|
<FocusTrap className="contents">
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">{t("publish.title")}</h3>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={onClose} aria-label={t("action.close")}>
|
<h3 className="font-title-md">{t("publish.title")}</h3>
|
||||||
<X className="w-4 h-4" aria-hidden="true" />
|
<span className="text-xs text-on-surface-variant">
|
||||||
</button>
|
{t("publish.stepIndicator", { current: stepNumber, total: 3, label: stepLabel })}
|
||||||
</div>
|
</span>
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
|
|
||||||
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
|
||||||
{classes.map((c) => (
|
|
||||||
<label
|
|
||||||
key={c.id}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedClasses.includes(c.id)}
|
|
||||||
onChange={() =>
|
|
||||||
setSelectedClasses(
|
|
||||||
selectedClasses.includes(c.id)
|
|
||||||
? selectedClasses.filter((x) => x !== c.id)
|
|
||||||
: [...selectedClasses, c.id],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{c.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">
|
<div className="p-4 space-y-3 overflow-y-auto flex-1">
|
||||||
{t("publish.availableAtLabel")}
|
{/* 步骤 1:选班级 + 时间 */}
|
||||||
</label>
|
{step === "select" && (
|
||||||
<input
|
<>
|
||||||
type="datetime-local"
|
<div>
|
||||||
value={availableAt}
|
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
|
||||||
onChange={(e) => setAvailableAt(e.target.value)}
|
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
||||||
className="w-full border rounded px-2 py-1 mt-1"
|
{classes.map((c) => (
|
||||||
/>
|
<label key={c.id} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedClasses.includes(c.id)}
|
||||||
|
onChange={() => handleSelectClass(c.id)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{c.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("publish.availableAtLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={availableAt}
|
||||||
|
onChange={(e) => setAvailableAt(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("publish.dueAtLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueAt}
|
||||||
|
onChange={(e) => setDueAt(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步骤 2:预览题目 + 总分 + 班级 */}
|
||||||
|
{step === "preview" && (
|
||||||
|
<>
|
||||||
|
<div className="bg-surface-container-high rounded p-3 text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
{t("publish.previewClassCount")}: <strong>{selectedClassCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("publish.previewQuestionCount")}: <strong>{items.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("publish.previewTotalScore")}: <strong>{totalScore}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-2">
|
||||||
|
{t("publish.previewQuestionList")}
|
||||||
|
</label>
|
||||||
|
<ol className="space-y-2 list-decimal list-inside text-sm">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${item.questionId}-${idx}`}
|
||||||
|
className="border border-outline-variant rounded p-2"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-on-surface-variant">
|
||||||
|
{t(questionTypeKey(item.inlineContent?.type ?? "single_choice"))} · {t("publish.previewSource", { source: t(`questionBank.source.${item.source}`) })}
|
||||||
|
</div>
|
||||||
|
{item.source === "inline" && item.inlineContent ? (
|
||||||
|
<div className="mt-1 text-sm">
|
||||||
|
{extractStemPreview(item.inlineContent.content) || t("publish.previewNoStem")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-sm font-mono text-xs">
|
||||||
|
ID: {item.questionId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||||
|
{t("publish.previewScore", { score: item.score })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步骤 3:确认发布 */}
|
||||||
|
{step === "confirm" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-primary" />
|
||||||
|
<span className="font-medium">{t("publish.confirmTitle")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-container-high rounded p-3 text-sm space-y-1">
|
||||||
|
<div>{t("publish.confirmClassCount", { count: selectedClassCount })}</div>
|
||||||
|
<div>{t("publish.confirmQuestionCount", { count: items.length })}</div>
|
||||||
|
<div>{t("publish.confirmTotalScore", { score: totalScore })}</div>
|
||||||
|
{availableAt && (
|
||||||
|
<div>{t("publish.confirmAvailableAt", { time: availableAt })}</div>
|
||||||
|
)}
|
||||||
|
{dueAt && (
|
||||||
|
<div>{t("publish.confirmDueAt", { time: dueAt })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
{t("publish.confirmWarning")}
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">
|
{/* 步骤导航按钮 */}
|
||||||
{t("publish.dueAtLabel")}
|
<div className="p-4 border-t flex justify-between gap-2">
|
||||||
</label>
|
{step === "select" && (
|
||||||
<input
|
<>
|
||||||
type="datetime-local"
|
<Button variant="outline" onClick={onClose}>
|
||||||
value={dueAt}
|
{t("action.cancel")}
|
||||||
onChange={(e) => setDueAt(e.target.value)}
|
</Button>
|
||||||
className="w-full border rounded px-2 py-1 mt-1"
|
<Button onClick={handleNextFromSelect}>
|
||||||
/>
|
{t("publish.next")} <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "preview" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleBackToSelect}>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> {t("publish.back")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNextFromPreview}>
|
||||||
|
{t("publish.next")} <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "confirm" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleBackToPreview} disabled={loading}>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> {t("publish.back")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePublish} disabled={loading}>
|
||||||
|
{loading ? t("publish.publishing") : t("publish.publish")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-error text-sm">{error}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("action.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handlePublish} disabled={loading}>
|
|
||||||
{loading ? t("publish.publishing") : t("publish.publish")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
|
||||||
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
|
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
|
||||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||||
import { X } from "lucide-react"
|
import { X, ChevronDown, ChevronRight } from "lucide-react"
|
||||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||||
import type { ExerciseItem } from "../types"
|
import type { ExerciseItem } from "../types"
|
||||||
import type { QuestionType } from "@/modules/questions/types"
|
import type { QuestionType } from "@/modules/questions/types"
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards"
|
||||||
|
|
||||||
// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言)
|
// 类型守卫:验证字符串是否为有效的 QuestionType(避免 as 断言)
|
||||||
function isQuestionType(v: string): v is QuestionType {
|
function isQuestionType(v: string): v is QuestionType {
|
||||||
@@ -118,6 +119,54 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V5-6:展开题目详情(题干/选项/答案)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-6:从 content 中安全提取题干
|
||||||
|
function extractStem(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content
|
||||||
|
if (isRecord(content)) {
|
||||||
|
const stem = content.stem
|
||||||
|
if (typeof stem === "string") return stem
|
||||||
|
const text = content.text
|
||||||
|
if (typeof text === "string") return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-6:从 content 中安全提取选项列表
|
||||||
|
function extractOptions(content: unknown): { label: string; text: string; isCorrect?: boolean }[] {
|
||||||
|
if (!isRecord(content)) return []
|
||||||
|
const options = content.options
|
||||||
|
if (!Array.isArray(options)) return []
|
||||||
|
return options
|
||||||
|
.filter((o): o is Record<string, unknown> => isRecord(o))
|
||||||
|
.map((o, i) => ({
|
||||||
|
label: typeof o.label === "string" ? o.label : String.fromCharCode(65 + i),
|
||||||
|
text: typeof o.text === "string" ? o.text : "",
|
||||||
|
isCorrect: typeof o.isCorrect === "boolean" ? o.isCorrect : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// V5-6:从 content 中安全提取答案
|
||||||
|
function extractAnswer(content: unknown): string {
|
||||||
|
if (!isRecord(content)) return ""
|
||||||
|
const answer = content.answer
|
||||||
|
if (typeof answer === "string") return answer
|
||||||
|
if (typeof answer === "number") return String(answer)
|
||||||
|
if (Array.isArray(answer)) {
|
||||||
|
return answer
|
||||||
|
.filter((a): a is string | number => typeof a === "string" || typeof a === "number")
|
||||||
|
.map((a) => String(a))
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div
|
<div
|
||||||
@@ -155,20 +204,90 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{questions.map((q) => (
|
{questions.map((q) => {
|
||||||
<div
|
const isExpanded = expandedId === q.id
|
||||||
key={q.id}
|
const stem = extractStem(q.content)
|
||||||
className="border rounded p-2 flex justify-between items-center"
|
const options = extractOptions(q.content)
|
||||||
>
|
const answer = extractAnswer(q.content)
|
||||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
return (
|
||||||
<span className="text-xs text-on-surface-variant mr-2">
|
<div
|
||||||
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
key={q.id}
|
||||||
</span>
|
className="border rounded p-2"
|
||||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
>
|
||||||
{t("questionBank.add")}
|
<div className="flex justify-between items-center">
|
||||||
</Button>
|
<button
|
||||||
</div>
|
className="flex-1 text-left flex items-start gap-2 mr-2"
|
||||||
))}
|
onClick={() => toggleExpand(q.id)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 mt-1 flex-shrink-0" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 mt-1 flex-shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm truncate flex-1">
|
||||||
|
{previewText(q.content)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-on-surface-variant mr-2 whitespace-nowrap">
|
||||||
|
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||||
|
{t("questionBank.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 pl-5 space-y-2 text-sm border-t pt-2">
|
||||||
|
{/* V5-6:题干 */}
|
||||||
|
{stem && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-medium">
|
||||||
|
{t("questionBank.stemLabel")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 whitespace-pre-wrap">{stem}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* V5-6:选项 */}
|
||||||
|
{options.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-medium">
|
||||||
|
{t("questionBank.optionsLabel")}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-0.5 space-y-0.5">
|
||||||
|
{options.map((opt, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className={`flex items-start gap-1 ${opt.isCorrect ? "text-primary font-medium" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">{opt.label}.</span>
|
||||||
|
<span>{opt.text}</span>
|
||||||
|
{opt.isCorrect && (
|
||||||
|
<span className="text-xs text-primary">✓</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* V5-6:答案 */}
|
||||||
|
{answer && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-medium">
|
||||||
|
{t("questionBank.correctAnswer")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-primary">{answer}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!stem && options.length === 0 && !answer && (
|
||||||
|
<p className="text-xs text-on-surface-variant italic">
|
||||||
|
{t("questionBank.noDetail")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
273
src/modules/lesson-preparation/components/schedule-dialog.tsx
Normal file
273
src/modules/lesson-preparation/components/schedule-dialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { X, Calendar, Trash2, Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getLessonPlanSchedulesAction,
|
||||||
|
createLessonPlanScheduleAction,
|
||||||
|
deleteLessonPlanScheduleAction,
|
||||||
|
} from "../actions-schedules";
|
||||||
|
import type { LessonPlanScheduleRecord } from "../data-access-schedules";
|
||||||
|
|
||||||
|
interface ScheduleOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
planId: string;
|
||||||
|
classes: ScheduleOption[];
|
||||||
|
onClose: () => void;
|
||||||
|
onScheduled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-7:课时绑定对话框
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 列出课案已绑定的课时
|
||||||
|
* 2. 添加新课时绑定(选班级 + 日期 + 节次 + 时长)
|
||||||
|
* 3. 删除课时绑定
|
||||||
|
*
|
||||||
|
* 不通过 service 注入,直接调用 actions(因为是新增功能,且仅教师使用)。
|
||||||
|
*/
|
||||||
|
export function ScheduleDialog({ planId, classes, onClose, onScheduled }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
const [schedules, setSchedules] = useState<LessonPlanScheduleRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [classId, setClassId] = useState("");
|
||||||
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
|
const [period, setPeriod] = useState(1);
|
||||||
|
const [durationMin, setDurationMin] = useState(40);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSchedules();
|
||||||
|
}, [planId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
async function loadSchedules() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getLessonPlanSchedulesAction(planId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setSchedules(res.data.items);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ScheduleDialog] load failed", e);
|
||||||
|
toast.error(t("error.getOne"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!classId) {
|
||||||
|
setError(t("schedule.selectClass"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scheduledDate) {
|
||||||
|
setError(t("schedule.selectDate"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await createLessonPlanScheduleAction({
|
||||||
|
planId,
|
||||||
|
classId,
|
||||||
|
scheduledDate,
|
||||||
|
period,
|
||||||
|
durationMin,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("schedule.addSuccess"));
|
||||||
|
void loadSchedules();
|
||||||
|
onScheduled?.();
|
||||||
|
} else {
|
||||||
|
setError(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ScheduleDialog] add failed", e);
|
||||||
|
setError(t("error.save"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await deleteLessonPlanScheduleAction(id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("schedule.deleteSuccess"));
|
||||||
|
void loadSchedules();
|
||||||
|
onScheduled?.();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.save"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ScheduleDialog] delete failed", e);
|
||||||
|
toast.error(t("error.save"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("schedule.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[560px] max-h-[80vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<FocusTrap className="contents">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 className="font-title-md flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t("schedule.title")}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1 space-y-4">
|
||||||
|
{/* 已绑定课时列表 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-2">
|
||||||
|
{t("schedule.boundList")}
|
||||||
|
</label>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-on-surface-variant text-center py-4">
|
||||||
|
{t("version.loading")}
|
||||||
|
</p>
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<p className="text-sm text-on-surface-variant text-center py-4">
|
||||||
|
{t("schedule.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{schedules.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-2 border rounded p-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex-1">
|
||||||
|
<strong>{s.className}</strong>
|
||||||
|
<span className="text-on-surface-variant ml-2">
|
||||||
|
{s.scheduledDate} · {t("schedule.period", { n: s.period })} · {t("schedule.duration", { n: s.durationMin })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="text-error hover:bg-error/10 p-1 rounded"
|
||||||
|
onClick={() => void handleDelete(s.id)}
|
||||||
|
aria-label={t("schedule.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 添加新绑定 */}
|
||||||
|
<div className="border-t pt-3 space-y-2">
|
||||||
|
<label className="text-sm font-medium block">
|
||||||
|
{t("schedule.addNew")}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-on-surface-variant">
|
||||||
|
{t("schedule.classLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={classId}
|
||||||
|
onChange={(e) => setClassId(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("schedule.selectClass")}</option>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-on-surface-variant">
|
||||||
|
{t("schedule.dateLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={(e) => setScheduledDate(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-on-surface-variant">
|
||||||
|
{t("schedule.periodLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{t("schedule.period", { n })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-on-surface-variant">
|
||||||
|
{t("schedule.durationLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={180}
|
||||||
|
value={durationMin}
|
||||||
|
onChange={(e) => setDurationMin(Number(e.target.value))}
|
||||||
|
className="w-full border rounded px-2 py-1 mt-0.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-error text-sm">{error}</p>}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("schedule.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t flex justify-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("action.close")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,39 @@ import type { TextbookPickerOption, ChapterPickerOption } from "../providers/les
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { SYSTEM_TEMPLATES } from "../constants";
|
import { SYSTEM_TEMPLATES } from "../constants";
|
||||||
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
import { Book, ChevronRight, FileText, Loader2, Search, Clock } from "lucide-react";
|
||||||
import type { LessonPlanTemplate } from "../types";
|
import type { LessonPlanTemplate } from "../types";
|
||||||
|
|
||||||
|
/** V5-9:localStorage 中最近使用教材的存储键 */
|
||||||
|
const RECENT_TEXTBOOKS_KEY = "lesson-prep:recent-textbooks";
|
||||||
|
const MAX_RECENT = 5;
|
||||||
|
|
||||||
|
/** 读取最近使用教材 ID 列表 */
|
||||||
|
function readRecentTextbookIds(): string[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(RECENT_TEXTBOOKS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr.filter((x): x is string => typeof x === "string").slice(0, MAX_RECENT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入最近使用教材 ID 列表(新选择放在最前,去重,最多 MAX_RECENT 条) */
|
||||||
|
function writeRecentTextbookId(id: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const prev = readRecentTextbookIds();
|
||||||
|
const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT);
|
||||||
|
window.localStorage.setItem(RECENT_TEXTBOOKS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function TemplatePicker() {
|
export function TemplatePicker() {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,6 +63,42 @@ export function TemplatePicker() {
|
|||||||
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
||||||
// P1-6:个人模板
|
// P1-6:个人模板
|
||||||
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
|
const [personalTemplates, setPersonalTemplates] = useState<LessonPlanTemplate[]>([]);
|
||||||
|
// V5-9:教材搜索 + 最近使用
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [recentIds, setRecentIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// V5-9:客户端挂载后读取最近使用教材
|
||||||
|
useEffect(() => {
|
||||||
|
setRecentIds(readRecentTextbookIds());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// V5-9:客户端模糊搜索过滤教材(标题/学科/年级/出版社)
|
||||||
|
const filteredTextbooks = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return textbooks;
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
return textbooks.filter((tb) => {
|
||||||
|
const title = (tb.title ?? "").toLowerCase();
|
||||||
|
const subject = (tb.subject ?? "").toLowerCase();
|
||||||
|
const grade = (tb.grade ?? "").toLowerCase();
|
||||||
|
return title.includes(q) || subject.includes(q) || grade.includes(q);
|
||||||
|
});
|
||||||
|
}, [textbooks, searchQuery]);
|
||||||
|
|
||||||
|
// V5-9:最近使用的教材(按 recentIds 顺序,且仍存在于 textbooks 列表中)
|
||||||
|
const recentTextbooks = useMemo(() => {
|
||||||
|
if (recentIds.length === 0) return [];
|
||||||
|
return recentIds
|
||||||
|
.map((id) => textbooks.find((tb) => tb.id === id))
|
||||||
|
.filter((tb): tb is TextbookPickerOption => tb !== undefined);
|
||||||
|
}, [recentIds, textbooks]);
|
||||||
|
|
||||||
|
// V5-9:选择教材时记录到 localStorage
|
||||||
|
const handleTextbookSelect = useCallback((id: string) => {
|
||||||
|
setTextbookId(id);
|
||||||
|
setChapterId("");
|
||||||
|
if (id) writeRecentTextbookId(id);
|
||||||
|
setRecentIds(readRecentTextbookIds());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 派生:当前教材的章节是否正在加载
|
// 派生:当前教材的章节是否正在加载
|
||||||
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
||||||
@@ -176,24 +242,70 @@ export function TemplatePicker() {
|
|||||||
) : textbooks.length === 0 ? (
|
) : textbooks.length === 0 ? (
|
||||||
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
|
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<div className="space-y-2">
|
||||||
value={textbookId}
|
{/* V5-9:搜索框 */}
|
||||||
onChange={(e) => {
|
<div className="relative">
|
||||||
setTextbookId(e.target.value);
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-on-surface-variant" aria-hidden="true" />
|
||||||
setChapterId("");
|
<input
|
||||||
}}
|
type="search"
|
||||||
required
|
value={searchQuery}
|
||||||
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
>
|
placeholder={t("picker.searchTextbookPlaceholder")}
|
||||||
<option value="">{t("picker.selectTextbook")}</option>
|
className="w-full border border-outline-variant rounded-lg pl-8 pr-3 py-2 bg-surface text-sm"
|
||||||
{textbooks.map((tb) => (
|
aria-label={t("picker.searchTextbookLabel")}
|
||||||
<option key={tb.id} value={tb.id}>
|
/>
|
||||||
{tb.title}
|
</div>
|
||||||
{tb.subject ? ` · ${tb.subject}` : ""}
|
|
||||||
{tb.grade ? ` · ${tb.grade}` : ""}
|
{/* V5-9:最近使用教材(无搜索词时显示) */}
|
||||||
</option>
|
{!searchQuery.trim() && recentTextbooks.length > 0 && (
|
||||||
))}
|
<div className="space-y-1">
|
||||||
</select>
|
<div className="text-xs font-medium text-on-surface-variant flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||||
|
{t("picker.recentSection")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{recentTextbooks.map((tb) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tb.id}
|
||||||
|
onClick={() => handleTextbookSelect(tb.id)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-1 rounded border transition-colors",
|
||||||
|
textbookId === tb.id
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-outline-variant hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tb.title}
|
||||||
|
{tb.grade ? ` · ${tb.grade}` : ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 教材下拉框(过滤后) */}
|
||||||
|
<select
|
||||||
|
value={textbookId}
|
||||||
|
onChange={(e) => handleTextbookSelect(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
||||||
|
>
|
||||||
|
<option value="">{t("picker.selectTextbook")}</option>
|
||||||
|
{filteredTextbooks.map((tb) => (
|
||||||
|
<option key={tb.id} value={tb.id}>
|
||||||
|
{tb.title}
|
||||||
|
{tb.subject ? ` · ${tb.subject}` : ""}
|
||||||
|
{tb.grade ? ` · ${tb.grade}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{searchQuery.trim() && filteredTextbooks.length === 0 && (
|
||||||
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
{t("picker.searchEmpty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
124
src/modules/lesson-preparation/components/version-diff-view.tsx
Normal file
124
src/modules/lesson-preparation/components/version-diff-view.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Plus, Minus, Pencil, Check } from "lucide-react";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
import { diffDocuments, hasChanges, type NodeDiff } from "../lib/version-diff";
|
||||||
|
import { getNodeColor } from "../lib/node-summary";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
oldDoc: LessonPlanDocument;
|
||||||
|
newDoc: LessonPlanDocument;
|
||||||
|
/** 旧版本号(用于标题展示) */
|
||||||
|
oldVersionNo?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5-16 T2:版本对比视图。
|
||||||
|
*
|
||||||
|
* 以列表形式展示两个版本文档的节点级差异,
|
||||||
|
* 支持 added/removed/modified/unchanged 四种状态标记。
|
||||||
|
*/
|
||||||
|
export function VersionDiffView({ oldDoc, newDoc, oldVersionNo }: Props) {
|
||||||
|
const t = useTranslations("lessonPreparation");
|
||||||
|
|
||||||
|
const result = useMemo(() => diffDocuments(oldDoc, newDoc), [oldDoc, newDoc]);
|
||||||
|
const changed = hasChanges(result);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 摘要条 */}
|
||||||
|
<div className="flex items-center gap-3 text-xs flex-wrap">
|
||||||
|
<span className="text-on-surface-variant">
|
||||||
|
{t("diff.summary", {
|
||||||
|
added: result.summary.added,
|
||||||
|
removed: result.summary.removed,
|
||||||
|
modified: result.summary.modified,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{oldVersionNo !== undefined && (
|
||||||
|
<span className="text-on-surface-variant">
|
||||||
|
{t("diff.comparing", { version: oldVersionNo })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!changed ? (
|
||||||
|
<div className="text-sm text-on-surface-variant p-3 rounded border border-outline-variant bg-surface-container-low">
|
||||||
|
{t("diff.noChanges")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{result.diffs
|
||||||
|
.filter((d) => d.type !== "unchanged")
|
||||||
|
.map((d, idx) => (
|
||||||
|
<DiffItem key={idx} diff={d} t={t} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* unchanged 计数(折叠) */}
|
||||||
|
{result.summary.unchanged > 0 && (
|
||||||
|
<div className="text-xs text-on-surface-variant flex items-center gap-1">
|
||||||
|
<Check className="w-3 h-3" aria-hidden="true" />
|
||||||
|
{t("diff.unchangedCount", { count: result.summary.unchanged })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffItem({
|
||||||
|
diff,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
diff: NodeDiff;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const node = diff.newNode ?? diff.oldNode;
|
||||||
|
const color = node ? getNodeColor(node.type) : "#999";
|
||||||
|
const title = node?.title || node?.type || "";
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
added: <Plus className="w-3.5 h-3.5 text-primary" aria-hidden="true" />,
|
||||||
|
removed: <Minus className="w-3.5 h-3.5 text-error" aria-hidden="true" />,
|
||||||
|
modified: <Pencil className="w-3.5 h-3.5 text-tertiary" aria-hidden="true" />,
|
||||||
|
unchanged: <Check className="w-3.5 h-3.5 text-on-surface-variant" aria-hidden="true" />,
|
||||||
|
}[diff.type];
|
||||||
|
|
||||||
|
const label = t(`diff.${diff.type}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 px-2.5 py-1.5 rounded border text-sm",
|
||||||
|
diff.type === "added" && "border-primary/40 bg-primary-container/20",
|
||||||
|
diff.type === "removed" && "border-error/40 bg-error-container/20",
|
||||||
|
diff.type === "modified" && "border-tertiary/40 bg-tertiary-container/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{icon}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-container-highest font-medium flex-shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
{diff.type === "modified" && diff.changedFields && diff.changedFields.length > 0 && (
|
||||||
|
<div className="text-xs text-on-surface-variant mt-0.5">
|
||||||
|
{t("diff.changedFields")}:{" "}
|
||||||
|
{diff.changedFields.map((f) => t(`diff.field.${f}`)).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,16 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog";
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { formatDateTime } from "@/shared/lib/utils";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import type { LessonPlanVersion } from "../types";
|
import { VersionDiffView } from "./version-diff-view";
|
||||||
|
import type { LessonPlanDocument, LessonPlanVersion } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
planId: string;
|
planId: string;
|
||||||
onReverted: () => void;
|
onReverted: () => void;
|
||||||
|
/** V5-16:当前文档(用于版本对比) */
|
||||||
|
currentDoc?: LessonPlanDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionHistoryDrawer({
|
export function VersionHistoryDrawer({
|
||||||
@@ -33,6 +36,7 @@ export function VersionHistoryDrawer({
|
|||||||
onClose,
|
onClose,
|
||||||
planId,
|
planId,
|
||||||
onReverted,
|
onReverted,
|
||||||
|
currentDoc,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const ctx = useLessonPlanContextSafe();
|
const ctx = useLessonPlanContextSafe();
|
||||||
@@ -40,6 +44,8 @@ export function VersionHistoryDrawer({
|
|||||||
const tracker = useLessonPlanTrackerSafe();
|
const tracker = useLessonPlanTrackerSafe();
|
||||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
/** V5-16:当前正在对比的版本(null 表示显示列表) */
|
||||||
|
const [comparingVersion, setComparingVersion] = useState<LessonPlanVersion | null>(null);
|
||||||
|
|
||||||
// P1-1 修复:ESC 键关闭抽屉(open 时才监听)
|
// P1-1 修复:ESC 键关闭抽屉(open 时才监听)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,56 +103,93 @@ export function VersionHistoryDrawer({
|
|||||||
<div className="flex-1 bg-black/30" onClick={onClose} />
|
<div className="flex-1 bg-black/30" onClick={onClose} />
|
||||||
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
||||||
<FocusTrap className="contents">
|
<FocusTrap className="contents">
|
||||||
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
|
{/* V5-16:版本对比视图(当 comparingVersion 存在时显示) */}
|
||||||
{loading ? (
|
{comparingVersion && currentDoc ? (
|
||||||
<VersionListSkeleton />
|
<div>
|
||||||
) : versions.length === 0 ? (
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-on-surface-variant">{t("version.empty")}</p>
|
<h3 className="font-headline-md text-headline-md">
|
||||||
) : (
|
{t("diff.comparing", { version: comparingVersion.versionNo })}
|
||||||
<div className="flex flex-col gap-2">
|
</h3>
|
||||||
{versions.map((v) => (
|
<Button
|
||||||
<div
|
variant="ghost"
|
||||||
key={v.id}
|
size="sm"
|
||||||
className="border border-outline-variant rounded-lg p-3"
|
onClick={() => setComparingVersion(null)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
{t("diff.back")}
|
||||||
<span className="font-title-md">v{v.versionNo}</span>
|
</Button>
|
||||||
{v.isAuto && (
|
</div>
|
||||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
<VersionDiffView
|
||||||
{t("version.auto")}
|
oldDoc={comparingVersion.content}
|
||||||
</span>
|
newDoc={currentDoc}
|
||||||
)}
|
oldVersionNo={comparingVersion.versionNo}
|
||||||
</div>
|
/>
|
||||||
<p className="text-sm text-on-surface-variant">
|
|
||||||
{v.label ?? t("version.manual")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
|
||||||
{formatDateTime(v.createdAt)}
|
|
||||||
</p>
|
|
||||||
<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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
|
||||||
|
{loading ? (
|
||||||
|
<VersionListSkeleton />
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<p className="text-on-surface-variant">{t("version.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className="border border-outline-variant rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-title-md">v{v.versionNo}</span>
|
||||||
|
{v.isAuto && (
|
||||||
|
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||||
|
{t("version.auto")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-on-surface-variant">
|
||||||
|
{v.label ?? t("version.manual")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-on-surface-variant mt-1">
|
||||||
|
{formatDateTime(v.createdAt)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
{/* V5-16 T2:对比当前按钮 */}
|
||||||
|
{currentDoc && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setComparingVersion(v)}
|
||||||
|
>
|
||||||
|
{t("diff.compareWithCurrent")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface BlockRenderProps {
|
|||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
chapterId?: string;
|
chapterId?: string;
|
||||||
classes?: { id: string; name: string }[];
|
classes?: { id: string; name: string }[];
|
||||||
|
/** V5-5:当前课案 ID(仅 RichTextBlock 用于素材库 picker 关联) */
|
||||||
|
planId?: string;
|
||||||
onUpdate: (data: BlockData) => void;
|
onUpdate: (data: BlockData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +192,8 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
|
|||||||
data={rest.data}
|
data={rest.data}
|
||||||
textbookId={rest.textbookId}
|
textbookId={rest.textbookId}
|
||||||
chapterId={rest.chapterId}
|
chapterId={rest.chapterId}
|
||||||
|
planId={rest.planId}
|
||||||
|
blockId={rest.blockId}
|
||||||
onUpdate={(d) => rest.onUpdate(d)}
|
onUpdate={(d) => rest.onUpdate(d)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
181
src/modules/lesson-preparation/data-access-schedules.ts
Normal file
181
src/modules/lesson-preparation/data-access-schedules.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* V5-7:课案-课时绑定数据访问层
|
||||||
|
*
|
||||||
|
* 将课案绑定到具体班级的某个日期/节次,支持 calendar-view 安排课时。
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { db } from "@/shared/db";
|
||||||
|
import { lessonPlanSchedules, classes } from "@/shared/db/schema";
|
||||||
|
import { and, eq, gte, lte, desc } from "drizzle-orm";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
|
/** 课时绑定记录 */
|
||||||
|
export interface LessonPlanScheduleRecord {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
classId: string;
|
||||||
|
className: string;
|
||||||
|
scheduledDate: string; // YYYY-MM-DD
|
||||||
|
period: number;
|
||||||
|
classScheduleId?: string | null;
|
||||||
|
durationMin: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 Date 转换为 YYYY-MM-DD 字符串(用于接口输出) */
|
||||||
|
function toDateStr(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询课案的所有课时绑定 */
|
||||||
|
export async function getSchedulesByPlanId(
|
||||||
|
planId: string,
|
||||||
|
): Promise<LessonPlanScheduleRecord[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: lessonPlanSchedules.id,
|
||||||
|
planId: lessonPlanSchedules.planId,
|
||||||
|
classId: lessonPlanSchedules.classId,
|
||||||
|
className: classes.name,
|
||||||
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
|
period: lessonPlanSchedules.period,
|
||||||
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
|
durationMin: lessonPlanSchedules.durationMin,
|
||||||
|
createdBy: lessonPlanSchedules.createdBy,
|
||||||
|
createdAt: lessonPlanSchedules.createdAt,
|
||||||
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
|
})
|
||||||
|
.from(lessonPlanSchedules)
|
||||||
|
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
||||||
|
.where(eq(lessonPlanSchedules.planId, planId))
|
||||||
|
.orderBy(desc(lessonPlanSchedules.scheduledDate));
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
planId: r.planId,
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className ?? "",
|
||||||
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
|
period: r.period,
|
||||||
|
classScheduleId: r.classScheduleId,
|
||||||
|
durationMin: r.durationMin,
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询教师在某日期范围内的课时绑定 */
|
||||||
|
export async function getSchedulesByDateRange(
|
||||||
|
teacherPlanIds: string[],
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
): Promise<LessonPlanScheduleRecord[]> {
|
||||||
|
if (teacherPlanIds.length === 0) return [];
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: lessonPlanSchedules.id,
|
||||||
|
planId: lessonPlanSchedules.planId,
|
||||||
|
classId: lessonPlanSchedules.classId,
|
||||||
|
className: classes.name,
|
||||||
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
|
period: lessonPlanSchedules.period,
|
||||||
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
|
durationMin: lessonPlanSchedules.durationMin,
|
||||||
|
createdBy: lessonPlanSchedules.createdBy,
|
||||||
|
createdAt: lessonPlanSchedules.createdAt,
|
||||||
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
|
})
|
||||||
|
.from(lessonPlanSchedules)
|
||||||
|
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
// 简化:仅按日期范围过滤(planId 过滤由调用方处理或 join)
|
||||||
|
gte(lessonPlanSchedules.scheduledDate, new Date(startDate)),
|
||||||
|
lte(lessonPlanSchedules.scheduledDate, new Date(endDate)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(lessonPlanSchedules.scheduledDate));
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((r) => teacherPlanIds.includes(r.planId))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
planId: r.planId,
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className ?? "",
|
||||||
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
|
period: r.period,
|
||||||
|
classScheduleId: r.classScheduleId,
|
||||||
|
durationMin: r.durationMin,
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建课时绑定 */
|
||||||
|
export async function createSchedule(input: {
|
||||||
|
planId: string;
|
||||||
|
classId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
period: number;
|
||||||
|
classScheduleId?: string;
|
||||||
|
durationMin?: number;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<LessonPlanScheduleRecord> {
|
||||||
|
const newId = createId();
|
||||||
|
await db.insert(lessonPlanSchedules).values({
|
||||||
|
id: newId,
|
||||||
|
planId: input.planId,
|
||||||
|
classId: input.classId,
|
||||||
|
scheduledDate: new Date(input.scheduledDate),
|
||||||
|
period: input.period,
|
||||||
|
classScheduleId: input.classScheduleId,
|
||||||
|
durationMin: input.durationMin ?? 40,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await db
|
||||||
|
.select({
|
||||||
|
id: lessonPlanSchedules.id,
|
||||||
|
planId: lessonPlanSchedules.planId,
|
||||||
|
classId: lessonPlanSchedules.classId,
|
||||||
|
className: classes.name,
|
||||||
|
scheduledDate: lessonPlanSchedules.scheduledDate,
|
||||||
|
period: lessonPlanSchedules.period,
|
||||||
|
classScheduleId: lessonPlanSchedules.classScheduleId,
|
||||||
|
durationMin: lessonPlanSchedules.durationMin,
|
||||||
|
createdBy: lessonPlanSchedules.createdBy,
|
||||||
|
createdAt: lessonPlanSchedules.createdAt,
|
||||||
|
updatedAt: lessonPlanSchedules.updatedAt,
|
||||||
|
})
|
||||||
|
.from(lessonPlanSchedules)
|
||||||
|
.leftJoin(classes, eq(lessonPlanSchedules.classId, classes.id))
|
||||||
|
.where(eq(lessonPlanSchedules.id, newId));
|
||||||
|
|
||||||
|
const r = created[0]!;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
planId: r.planId,
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className ?? "",
|
||||||
|
scheduledDate: toDateStr(r.scheduledDate),
|
||||||
|
period: r.period,
|
||||||
|
classScheduleId: r.classScheduleId,
|
||||||
|
durationMin: r.durationMin,
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除课时绑定 */
|
||||||
|
export async function deleteSchedule(id: string): Promise<void> {
|
||||||
|
await db.delete(lessonPlanSchedules).where(eq(lessonPlanSchedules.id, id));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
TextbookContentNodeData,
|
TextbookContentNodeData,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { defaultDataForType } from "../lib/document-migration";
|
import { defaultDataForType } from "../lib/document-migration";
|
||||||
|
import { computeAutoLayout } from "../lib/auto-layout";
|
||||||
import type { EditorState } from "./use-lesson-plan-editor";
|
import type { EditorState } from "./use-lesson-plan-editor";
|
||||||
|
|
||||||
export interface EditorSlice {
|
export interface EditorSlice {
|
||||||
@@ -40,6 +41,8 @@ export interface EditorSlice {
|
|||||||
connect: (source: string, target: string) => void;
|
connect: (source: string, target: string) => void;
|
||||||
disconnect: (edgeId: string) => void;
|
disconnect: (edgeId: string) => void;
|
||||||
setEdges: (edges: AnyLessonPlanEdge[]) => void;
|
setEdges: (edges: AnyLessonPlanEdge[]) => void;
|
||||||
|
/** V5-8:自动布局,使用 dagre 计算并应用新位置 */
|
||||||
|
autoLayout: (direction?: "TB" | "LR") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
||||||
@@ -62,12 +65,16 @@ export const createEditorSlice: StateCreator<
|
|||||||
anchors: [],
|
anchors: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
setTitle: (title) => set({ title, isDirty: true }),
|
setTitle: (title) => {
|
||||||
|
get().pushHistory();
|
||||||
|
set({ title, isDirty: true });
|
||||||
|
},
|
||||||
setPlanId: (planId) => set({ planId }),
|
setPlanId: (planId) => set({ planId }),
|
||||||
|
|
||||||
addNode: (type, position, title) => {
|
addNode: (type, position, title) => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
const state = get();
|
const state = get();
|
||||||
|
state.pushHistory(); // V5-2:撤销/重做
|
||||||
const teachingNodes = state.doc.nodes.filter(
|
const teachingNodes = state.doc.nodes.filter(
|
||||||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
);
|
);
|
||||||
@@ -91,7 +98,8 @@ export const createEditorSlice: StateCreator<
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNode: (id, patch) =>
|
updateNode: (id, patch) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
@@ -104,9 +112,12 @@ export const createEditorSlice: StateCreator<
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
updateNodePosition: (id, position) =>
|
updateNodePosition: (id, position) => {
|
||||||
|
// V5-2 注:拖拽过程中会产生大量 position 更新,仅在拖拽开始时推一次 history。
|
||||||
|
// 调用方应在 onDragStart 时调用 pushHistory,此处不重复推入。
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
@@ -119,9 +130,11 @@ export const createEditorSlice: StateCreator<
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
removeNode: (id) =>
|
removeNode: (id) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const remainingTeachingNodes = reindex(
|
const remainingTeachingNodes = reindex(
|
||||||
s.doc.nodes.filter(
|
s.doc.nodes.filter(
|
||||||
@@ -146,9 +159,11 @@ export const createEditorSlice: StateCreator<
|
|||||||
isDirty: true,
|
isDirty: true,
|
||||||
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
|
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
updateTextbookContent: (data) =>
|
updateTextbookContent: (data) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
@@ -159,7 +174,8 @@ export const createEditorSlice: StateCreator<
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
getTextbookContentNode: () => {
|
getTextbookContentNode: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -171,6 +187,7 @@ export const createEditorSlice: StateCreator<
|
|||||||
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
|
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
|
||||||
const anchorId = createId();
|
const anchorId = createId();
|
||||||
const state = get();
|
const state = get();
|
||||||
|
state.pushHistory(); // V5-2:撤销/重做
|
||||||
const textbookNodeId = state.doc.textbookContentNodeId;
|
const textbookNodeId = state.doc.textbookContentNodeId;
|
||||||
|
|
||||||
const anchor: NodeAnchor = {
|
const anchor: NodeAnchor = {
|
||||||
@@ -202,7 +219,8 @@ export const createEditorSlice: StateCreator<
|
|||||||
return anchorId;
|
return anchorId;
|
||||||
},
|
},
|
||||||
|
|
||||||
removeAnchor: (anchorId) =>
|
removeAnchor: (anchorId) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
@@ -212,9 +230,11 @@ export const createEditorSlice: StateCreator<
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
updateAnchor: (anchorId, patch) =>
|
updateAnchor: (anchorId, patch) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
@@ -223,9 +243,11 @@ export const createEditorSlice: StateCreator<
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
connect: (source, target) =>
|
connect: (source, target) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (
|
if (
|
||||||
s.doc.edges.some((e) => e.source === source && e.target === target)
|
s.doc.edges.some((e) => e.source === source && e.target === target)
|
||||||
@@ -241,17 +263,43 @@ export const createEditorSlice: StateCreator<
|
|||||||
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
|
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
disconnect: (edgeId) =>
|
disconnect: (edgeId) => {
|
||||||
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
doc: {
|
doc: {
|
||||||
...s.doc,
|
...s.doc,
|
||||||
edges: s.doc.edges.filter((e) => e.id !== edgeId),
|
edges: s.doc.edges.filter((e) => e.id !== edgeId),
|
||||||
},
|
},
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
setEdges: (edges) =>
|
setEdges: (edges) => {
|
||||||
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
|
get().pushHistory(); // V5-2:撤销/重做
|
||||||
|
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
autoLayout: (direction = "TB") => {
|
||||||
|
const state = get();
|
||||||
|
const positions = computeAutoLayout(state.doc.nodes, state.doc.edges, { direction });
|
||||||
|
if (positions.size === 0) return;
|
||||||
|
state.pushHistory(); // V5-2:撤销/重做
|
||||||
|
set((s) => ({
|
||||||
|
doc: {
|
||||||
|
...s.doc,
|
||||||
|
nodes: s.doc.nodes.map((n) => {
|
||||||
|
const p = positions.get(n.id);
|
||||||
|
return p
|
||||||
|
? n.type === "textbook_content"
|
||||||
|
? ({ ...n, position: p } as TextbookContentNode)
|
||||||
|
: ({ ...n, position: p } as LessonPlanNode)
|
||||||
|
: n;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
}));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
79
src/modules/lesson-preparation/hooks/history-slice.ts
Normal file
79
src/modules/lesson-preparation/hooks/history-slice.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { LessonPlanDocument } from "../types";
|
||||||
|
import type { EditorState } from "./use-lesson-plan-editor";
|
||||||
|
|
||||||
|
/** V5-2:撤销/重做栈上限(用户要求至少 50 步) */
|
||||||
|
export const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
/** 历史快照(仅记录 doc + title,避免完整 state 序列化) */
|
||||||
|
export interface HistorySnapshot {
|
||||||
|
doc: LessonPlanDocument;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorySlice {
|
||||||
|
past: HistorySnapshot[];
|
||||||
|
future: HistorySnapshot[];
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
/** 在 mutation 之前调用:把当前 doc+title 推入 past,清空 future */
|
||||||
|
pushHistory: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
/** hydrate/replaceDoc 时清空历史,避免跨课案污染 */
|
||||||
|
clearHistory: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createHistorySlice: StateCreator<
|
||||||
|
EditorState,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
HistorySlice
|
||||||
|
> = (set, get) => ({
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
|
||||||
|
canUndo: () => get().past.length > 0,
|
||||||
|
canRedo: () => get().future.length > 0,
|
||||||
|
|
||||||
|
pushHistory: () => {
|
||||||
|
const state = get();
|
||||||
|
const snapshot: HistorySnapshot = { doc: state.doc, title: state.title };
|
||||||
|
set((s) => ({
|
||||||
|
past: [...s.past, snapshot].slice(-MAX_HISTORY),
|
||||||
|
future: [], // 新动作清空 redo 栈
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.past.length === 0) return;
|
||||||
|
const previous = state.past[state.past.length - 1]!;
|
||||||
|
const current: HistorySnapshot = { doc: state.doc, title: state.title };
|
||||||
|
set((s) => ({
|
||||||
|
doc: previous.doc,
|
||||||
|
title: previous.title,
|
||||||
|
past: s.past.slice(0, -1),
|
||||||
|
future: [current, ...s.future].slice(0, MAX_HISTORY),
|
||||||
|
isDirty: true,
|
||||||
|
selectedNodeId: null, // 撤销后重置选中节点,避免悬空引用
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.future.length === 0) return;
|
||||||
|
const next = state.future[0]!;
|
||||||
|
const current: HistorySnapshot = { doc: state.doc, title: state.title };
|
||||||
|
set((s) => ({
|
||||||
|
doc: next.doc,
|
||||||
|
title: next.title,
|
||||||
|
past: [...s.past, current].slice(-MAX_HISTORY),
|
||||||
|
future: s.future.slice(1),
|
||||||
|
isDirty: true,
|
||||||
|
selectedNodeId: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory: () => set({ past: [], future: [] }),
|
||||||
|
});
|
||||||
@@ -4,22 +4,26 @@ import { create } from "zustand";
|
|||||||
import { createEditorSlice, type EditorSlice } from "./editor-slice";
|
import { createEditorSlice, type EditorSlice } from "./editor-slice";
|
||||||
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
|
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
|
||||||
import { createVersionSlice, type VersionSlice } from "./version-slice";
|
import { createVersionSlice, type VersionSlice } from "./version-slice";
|
||||||
|
import { createHistorySlice, type HistorySlice } from "./history-slice";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V4 P2-6 修复:将单体 Zustand store(原 303 行)拆分为 3 个独立 slice。
|
* V4 P2-6 修复:将单体 Zustand store(原 303 行)拆分为 3 个独立 slice。
|
||||||
|
* V5-2 新增:history-slice 撤销/重做栈(50 步上限)。
|
||||||
*
|
*
|
||||||
* - editor-slice: 文档结构(planId/title/doc)及所有文档操作方法
|
* - editor-slice: 文档结构(planId/title/doc)及所有文档操作方法
|
||||||
* - selection-slice: 节点选中状态(selectedNodeId / selectNode)
|
* - selection-slice: 节点选中状态(selectedNodeId / selectNode)
|
||||||
* - version-slice: 草稿版本与保存状态(isDirty/isSaving/lastSavedAt/hydrate/markSaved/replaceDoc)
|
* - version-slice: 草稿版本与保存状态(isDirty/isSaving/lastSavedAt/saveError/isOnline)
|
||||||
|
* - history-slice: 撤销/重做栈(past/future/canUndo/canRedo/undo/redo)
|
||||||
*
|
*
|
||||||
* 主文件仅负责组合 slice 并导出统一的 EditorState 类型,方便测试与维护。
|
* 主文件仅负责组合 slice 并导出统一的 EditorState 类型,方便测试与维护。
|
||||||
* 各 slice 通过 `import type { EditorState }` 引用合并后的类型,TypeScript
|
* 各 slice 通过 `import type { EditorState }` 引用合并后的类型,TypeScript
|
||||||
* 编译后该类型导入会被完全移除,运行时无循环依赖。
|
* 编译后该类型导入会被完全移除,运行时无循环依赖。
|
||||||
*/
|
*/
|
||||||
export type EditorState = EditorSlice & SelectionSlice & VersionSlice;
|
export type EditorState = EditorSlice & SelectionSlice & VersionSlice & HistorySlice;
|
||||||
|
|
||||||
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
|
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
|
||||||
...createEditorSlice(...a),
|
...createEditorSlice(...a),
|
||||||
...createSelectionSlice(...a),
|
...createSelectionSlice(...a),
|
||||||
...createVersionSlice(...a),
|
...createVersionSlice(...a),
|
||||||
|
...createHistorySlice(...a),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ export interface VersionSlice {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
lastSavedAt: number | null;
|
lastSavedAt: number | null;
|
||||||
|
/** V5-1:自动保存失败标记,供 UI 显示兜底提示与重试按钮 */
|
||||||
|
saveError: boolean;
|
||||||
|
/** V5-1:网络在线状态,供 UI 显示断网提示 */
|
||||||
|
isOnline: boolean;
|
||||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||||
markSaved: () => void;
|
markSaved: () => void;
|
||||||
setSaving: (saving: boolean) => void;
|
setSaving: (saving: boolean) => void;
|
||||||
replaceDoc: (doc: LessonPlanDocument) => void;
|
replaceDoc: (doc: LessonPlanDocument) => void;
|
||||||
|
/** V5-1:标记保存失败/成功 */
|
||||||
|
setSaveError: (hasError: boolean) => void;
|
||||||
|
/** V5-1:更新在线状态(监听 window online/offline 事件) */
|
||||||
|
setOnline: (online: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createVersionSlice: StateCreator<
|
export const createVersionSlice: StateCreator<
|
||||||
@@ -21,6 +29,8 @@ export const createVersionSlice: StateCreator<
|
|||||||
isDirty: false,
|
isDirty: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
lastSavedAt: null,
|
lastSavedAt: null,
|
||||||
|
saveError: false,
|
||||||
|
isOnline: true,
|
||||||
|
|
||||||
hydrate: (planId, title, doc) =>
|
hydrate: (planId, title, doc) =>
|
||||||
set({
|
set({
|
||||||
@@ -30,9 +40,14 @@ export const createVersionSlice: StateCreator<
|
|||||||
isDirty: false,
|
isDirty: false,
|
||||||
lastSavedAt: Date.now(),
|
lastSavedAt: Date.now(),
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
saveError: false,
|
||||||
|
past: [],
|
||||||
|
future: [], // V5-2:hydrate 时清空历史,避免跨课案污染
|
||||||
}),
|
}),
|
||||||
|
|
||||||
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
|
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now(), saveError: false }),
|
||||||
setSaving: (saving) => set({ isSaving: saving }),
|
setSaving: (saving) => set({ isSaving: saving }),
|
||||||
replaceDoc: (doc) => set({ doc, isDirty: false }),
|
replaceDoc: (doc) => set({ doc, isDirty: false, saveError: false, past: [], future: [] }),
|
||||||
|
setSaveError: (hasError) => set({ saveError: hasError }),
|
||||||
|
setOnline: (online) => set({ isOnline: online }),
|
||||||
});
|
});
|
||||||
|
|||||||
235
src/modules/lesson-preparation/lib/ai-differentiation.ts
Normal file
235
src/modules/lesson-preparation/lib/ai-differentiation.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* V5-21 A3/A4/A5:AI 差异化生成 + 课标实时核对 + 评估可解释。
|
||||||
|
*
|
||||||
|
* - A3:根据课案内容生成基础/提高/拓展三个层次的差异化教学建议
|
||||||
|
* - A4:课标实时核对(检查课案是否覆盖教材课标要求)
|
||||||
|
* - A5:评估可解释(对 exercise 节点给出评分依据说明)
|
||||||
|
*
|
||||||
|
* 复用 ai-feedback.ts 的 AI 调用模式,纯服务端模块。
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { LessonPlanDocument, LessonPlanNode, DifferentiationLevel } from "../types";
|
||||||
|
|
||||||
|
/** A3:差异化教学建议 */
|
||||||
|
export interface DifferentiationSuggestion {
|
||||||
|
level: DifferentiationLevel;
|
||||||
|
/** 该层次的具体教学建议 */
|
||||||
|
suggestions: string[];
|
||||||
|
/** 适用学生描述 */
|
||||||
|
targetStudents: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4:课标核对结果 */
|
||||||
|
export interface CurriculumCheckItem {
|
||||||
|
/** 课标要求(知识点名称) */
|
||||||
|
requirement: string;
|
||||||
|
/** 是否已覆盖 */
|
||||||
|
covered: boolean;
|
||||||
|
/** 覆盖方式说明(如已覆盖,说明在哪个节点覆盖) */
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A5:可解释评估 */
|
||||||
|
export interface ExplainableAssessment {
|
||||||
|
/** 节点 ID */
|
||||||
|
nodeId: string;
|
||||||
|
/** 评估结论 */
|
||||||
|
conclusion: string;
|
||||||
|
/** 评分依据(可解释性) */
|
||||||
|
rationale: string;
|
||||||
|
/** 改进建议 */
|
||||||
|
suggestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod schemas
|
||||||
|
const DifferentiationSuggestionSchema = z.object({
|
||||||
|
level: z.enum(["basic", "intermediate", "advanced"]),
|
||||||
|
suggestions: z.array(z.string()),
|
||||||
|
targetStudents: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CurriculumCheckItemSchema = z.object({
|
||||||
|
requirement: z.string(),
|
||||||
|
covered: z.boolean(),
|
||||||
|
explanation: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExplainableAssessmentSchema = z.object({
|
||||||
|
nodeId: z.string(),
|
||||||
|
conclusion: z.string(),
|
||||||
|
rationale: z.string(),
|
||||||
|
suggestion: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DifferentiationResultSchema = z.object({
|
||||||
|
items: z.array(DifferentiationSuggestionSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CurriculumCheckResultSchema = z.object({
|
||||||
|
items: z.array(CurriculumCheckItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ExplainableAssessmentResultSchema = z.object({
|
||||||
|
items: z.array(ExplainableAssessmentSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AI_DIFFERENTIATION_PROMPT = `你是教学设计专家。请基于以下课案内容,为三个层次的学生生成差异化教学建议:
|
||||||
|
- basic(基础生):需要夯实基础
|
||||||
|
- intermediate(中等生):需要巩固提升
|
||||||
|
- advanced(学优生):需要拓展延伸
|
||||||
|
|
||||||
|
课案内容:
|
||||||
|
---
|
||||||
|
{doc}
|
||||||
|
---
|
||||||
|
|
||||||
|
返回 JSON 对象:{ items: [{ level, suggestions: [建议1, 建议2], targetStudents: "适用学生描述" }] }`;
|
||||||
|
|
||||||
|
const AI_CURRICULUM_CHECK_PROMPT = `你是教学设计专家。请核对以下课案是否覆盖教材课标要求。
|
||||||
|
|
||||||
|
课案内容:
|
||||||
|
---
|
||||||
|
{doc}
|
||||||
|
---
|
||||||
|
|
||||||
|
教材知识点列表:{kpList}
|
||||||
|
|
||||||
|
返回 JSON 对象:{ items: [{ requirement: "知识点名称", covered: true/false, explanation: "覆盖方式说明" }] }`;
|
||||||
|
|
||||||
|
const AI_EXPLAINABLE_PROMPT = `你是教学评价专家。请对以下课案中的练习节点给出可解释的评估。
|
||||||
|
|
||||||
|
课案内容:
|
||||||
|
---
|
||||||
|
{doc}
|
||||||
|
---
|
||||||
|
|
||||||
|
返回 JSON 对象:{ items: [{ nodeId: "节点ID", conclusion: "评估结论", rationale: "评分依据", suggestion: "改进建议" }] }`;
|
||||||
|
|
||||||
|
/** 安全提取节点文本 */
|
||||||
|
function extractNodeText(node: LessonPlanNode): string {
|
||||||
|
const data = node.data as unknown;
|
||||||
|
if (!isRecord(data)) return "";
|
||||||
|
const html = typeof data.html === "string" ? data.html : "";
|
||||||
|
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
|
||||||
|
return html || sourceText || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建课案摘要供 AI 分析 */
|
||||||
|
function buildDocSummary(doc: LessonPlanDocument): string {
|
||||||
|
const teachingNodes = doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
|
);
|
||||||
|
return JSON.stringify(
|
||||||
|
teachingNodes.slice(0, 20).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
stage: n.stage,
|
||||||
|
differentiation: n.differentiation,
|
||||||
|
text: extractNodeText(n).slice(0, 200),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A3:AI 差异化教学建议生成。
|
||||||
|
*/
|
||||||
|
export async function generateDifferentiationSuggestions(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<DifferentiationSuggestion[]> {
|
||||||
|
const teachingNodes = doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
|
);
|
||||||
|
if (teachingNodes.length === 0) return [];
|
||||||
|
|
||||||
|
const prompt = AI_DIFFERENTIATION_PROMPT.replace("{doc}", buildDocSummary(doc));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await createAiChatCompletion({
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||||
|
temperature: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
|
const validated = DifferentiationResultSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) return [];
|
||||||
|
|
||||||
|
return validated.data.items;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A4:AI 课标实时核对。
|
||||||
|
*/
|
||||||
|
export async function checkCurriculumAlignment(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
knowledgePoints: { id: string; name: string }[],
|
||||||
|
): Promise<CurriculumCheckItem[]> {
|
||||||
|
if (knowledgePoints.length === 0) return [];
|
||||||
|
|
||||||
|
const prompt = AI_CURRICULUM_CHECK_PROMPT
|
||||||
|
.replace("{doc}", buildDocSummary(doc))
|
||||||
|
.replace("{kpList}", JSON.stringify(knowledgePoints.slice(0, 50)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await createAiChatCompletion({
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
|
const validated = CurriculumCheckResultSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) return [];
|
||||||
|
|
||||||
|
return validated.data.items;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A5:AI 可解释评估。
|
||||||
|
*/
|
||||||
|
export async function generateExplainableAssessment(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<ExplainableAssessment[]> {
|
||||||
|
const exerciseNodes = doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type === "exercise",
|
||||||
|
);
|
||||||
|
if (exerciseNodes.length === 0) return [];
|
||||||
|
|
||||||
|
const prompt = AI_EXPLAINABLE_PROMPT.replace("{doc}", buildDocSummary(doc));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await createAiChatCompletion({
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||||
|
temperature: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
|
const validated = ExplainableAssessmentResultSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) return [];
|
||||||
|
|
||||||
|
return validated.data.items;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/modules/lesson-preparation/lib/ai-feedback.ts
Normal file
135
src/modules/lesson-preparation/lib/ai-feedback.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* V5-17 A1/A2:AI 反馈闭环 + 解释性展示。
|
||||||
|
*
|
||||||
|
* 调用 AI 对课案文档进行教学评一致性反馈,返回结构化建议。
|
||||||
|
* 反馈包含:
|
||||||
|
* - strengths:课案优点
|
||||||
|
* - improvements:改进建议
|
||||||
|
* - alignment:教学评一致性评估
|
||||||
|
* - differentiation:差异化教学建议
|
||||||
|
*
|
||||||
|
* 每条建议附带 reason(解释性展示),帮助教师理解 AI 判断依据。
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||||
|
import { isRecord } from "@/shared/lib/type-guards";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { LessonPlanDocument, LessonPlanNode } from "../types";
|
||||||
|
|
||||||
|
/** AI 反馈单条建议 */
|
||||||
|
export interface AiFeedbackItem {
|
||||||
|
/** i18n 键后缀(feedback.* 命名空间下) */
|
||||||
|
category: "strengths" | "improvements" | "alignment" | "differentiation";
|
||||||
|
/** 建议标题 */
|
||||||
|
title: string;
|
||||||
|
/** 解释性理由(A2:解释性展示) */
|
||||||
|
reason: string;
|
||||||
|
/** 关联节点 ID(如适用) */
|
||||||
|
nodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 反馈结果 */
|
||||||
|
export interface AiFeedbackResult {
|
||||||
|
items: AiFeedbackItem[];
|
||||||
|
/** 整体评分(0-100) */
|
||||||
|
overallScore: number;
|
||||||
|
/** 摘要 */
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedbackItemSchema = z.object({
|
||||||
|
category: z.enum(["strengths", "improvements", "alignment", "differentiation"]),
|
||||||
|
title: z.string().min(1),
|
||||||
|
reason: z.string(),
|
||||||
|
nodeId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FeedbackResultSchema = z.object({
|
||||||
|
items: z.array(FeedbackItemSchema),
|
||||||
|
overallScore: z.number().min(0).max(100),
|
||||||
|
summary: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AI_FEEDBACK_PROMPT_TEMPLATE = `你是资深教学设计专家。请对以下课案文档进行教学评一致性评估,给出结构化反馈。
|
||||||
|
|
||||||
|
课案文档(JSON):
|
||||||
|
---
|
||||||
|
{doc}
|
||||||
|
---
|
||||||
|
|
||||||
|
请从四个维度评估:
|
||||||
|
1. strengths:课案优点
|
||||||
|
2. improvements:改进建议
|
||||||
|
3. alignment:教学评一致性(目标-教学-评价是否对齐)
|
||||||
|
4. differentiation:差异化教学建议
|
||||||
|
|
||||||
|
返回 JSON 对象,含:
|
||||||
|
- items:数组,每项含 category(维度)/title(建议标题)/reason(解释性理由,说明为何给出此建议)/nodeId(关联节点 ID,可选)
|
||||||
|
- overallScore:整体评分 0-100
|
||||||
|
- summary:一句话摘要
|
||||||
|
|
||||||
|
注意:reason 字段必须解释判断依据,帮助教师理解。`;
|
||||||
|
|
||||||
|
/** 安全提取节点文本用于 AI prompt */
|
||||||
|
function extractNodeText(node: LessonPlanNode): string {
|
||||||
|
const data = node.data as unknown;
|
||||||
|
if (!isRecord(data)) return "";
|
||||||
|
const html = typeof data.html === "string" ? data.html : "";
|
||||||
|
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
|
||||||
|
return html || sourceText || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 AI 对课案文档生成结构化反馈。
|
||||||
|
*
|
||||||
|
* @param doc 课案文档
|
||||||
|
* @returns AI 反馈结果;AI 不可用时返回空结果
|
||||||
|
*/
|
||||||
|
export async function generateLessonPlanFeedback(
|
||||||
|
doc: LessonPlanDocument,
|
||||||
|
): Promise<AiFeedbackResult> {
|
||||||
|
// 提取教学节点摘要(排除正文节点,控制 token 用量)
|
||||||
|
const teachingNodes = doc.nodes.filter(
|
||||||
|
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||||
|
);
|
||||||
|
if (teachingNodes.length === 0) {
|
||||||
|
return { items: [], overallScore: 0, summary: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docSummary = teachingNodes.slice(0, 20).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
stage: n.stage,
|
||||||
|
differentiation: n.differentiation,
|
||||||
|
text: extractNodeText(n).slice(0, 200),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const prompt = AI_FEEDBACK_PROMPT_TEMPLATE.replace(
|
||||||
|
"{doc}",
|
||||||
|
JSON.stringify(docSummary),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await createAiChatCompletion({
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||||
|
temperature: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从返回内容中提取 JSON 对象
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) return { items: [], overallScore: 0, summary: "" };
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||||
|
const validated = FeedbackResultSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) {
|
||||||
|
return { items: [], overallScore: 0, summary: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated.data;
|
||||||
|
} catch {
|
||||||
|
return { items: [], overallScore: 0, summary: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/modules/lesson-preparation/lib/auto-layout.ts
Normal file
82
src/modules/lesson-preparation/lib/auto-layout.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* V5-8:画布自动布局
|
||||||
|
*
|
||||||
|
* 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。
|
||||||
|
* 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。
|
||||||
|
*/
|
||||||
|
import dagre from "@dagrejs/dagre";
|
||||||
|
import type {
|
||||||
|
AnyLessonPlanNode,
|
||||||
|
AnyLessonPlanEdge,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export interface AutoLayoutOptions {
|
||||||
|
/** 布局方向:TB(上→下)/ LR(左→右),默认 TB */
|
||||||
|
direction?: "TB" | "LR";
|
||||||
|
/** 节点宽度,默认 240 */
|
||||||
|
nodeWidth?: number;
|
||||||
|
/** 节点高度,默认 120 */
|
||||||
|
nodeHeight?: number;
|
||||||
|
/** 节点水平间距,默认 40 */
|
||||||
|
rankSep?: number;
|
||||||
|
/** 节点垂直间距,默认 60 */
|
||||||
|
nodeSep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算自动布局,返回每个节点的新位置(含原始位置作为兜底)
|
||||||
|
*/
|
||||||
|
export function computeAutoLayout(
|
||||||
|
nodes: AnyLessonPlanNode[],
|
||||||
|
edges: AnyLessonPlanEdge[],
|
||||||
|
options: AutoLayoutOptions = {},
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const {
|
||||||
|
direction = "TB",
|
||||||
|
nodeWidth = 240,
|
||||||
|
nodeHeight = 120,
|
||||||
|
rankSep = 60,
|
||||||
|
nodeSep = 40,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const result = new Map<string, { x: number; y: number }>();
|
||||||
|
|
||||||
|
if (nodes.length === 0) return result;
|
||||||
|
|
||||||
|
const g = new dagre.graphlib.Graph();
|
||||||
|
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep });
|
||||||
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
// 仅对教学节点布局(正文节点固定位置)
|
||||||
|
const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content");
|
||||||
|
const layoutableIds = new Set(layoutableNodes.map((n) => n.id));
|
||||||
|
|
||||||
|
for (const node of layoutableNodes) {
|
||||||
|
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅添加两端都可布局的 flow 边
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (edge.type !== "flow") continue;
|
||||||
|
if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue;
|
||||||
|
g.setEdge(edge.source, edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 孤立节点(无边)也要 setNode,dagre 会自动排列
|
||||||
|
dagre.layout(g);
|
||||||
|
|
||||||
|
for (const node of layoutableNodes) {
|
||||||
|
const laid = g.node(node.id);
|
||||||
|
if (laid) {
|
||||||
|
// dagre 返回中心点,React Flow 使用左上角,需减去半宽/半高
|
||||||
|
result.set(node.id, {
|
||||||
|
x: laid.x - nodeWidth / 2,
|
||||||
|
y: laid.y - nodeHeight / 2,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.set(node.id, node.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
137
src/modules/lesson-preparation/lib/consistency-check.ts
Normal file
137
src/modules/lesson-preparation/lib/consistency-check.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* V5-19 T3:目标-评价一致性校验(教学评一致性)。
|
||||||
|
*
|
||||||
|
* 校验规则:
|
||||||
|
* 1. 每个教学目标(objective 节点)应至少被一个评价(exercise 节点)覆盖。
|
||||||
|
* 2. 每个 exercise 节点应至少关联一个知识点。
|
||||||
|
* 3. 课案应至少包含一个 objective 与一个 exercise(仅 warning,不阻断保存)。
|
||||||
|
*
|
||||||
|
* 输出为纯数据结构(ConsistencyResult),UI 层负责渲染。
|
||||||
|
* 该模块为纯函数,无副作用,便于单测。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ExerciseBlockData,
|
||||||
|
LessonPlanDocument,
|
||||||
|
LessonPlanNode,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** 校验级别 */
|
||||||
|
export type ConsistencySeverity = "warning" | "info";
|
||||||
|
|
||||||
|
/** 单条校验结果 */
|
||||||
|
export interface ConsistencyIssue {
|
||||||
|
/** i18n 键后缀(consistency.* 命名空间下) */
|
||||||
|
code:
|
||||||
|
| "objectiveNotAssessed"
|
||||||
|
| "exerciseWithoutObjective"
|
||||||
|
| "noObjective"
|
||||||
|
| "noExercise"
|
||||||
|
| "exerciseNoKnowledgePoint"
|
||||||
|
| "objectiveNoKnowledgePoint";
|
||||||
|
severity: ConsistencySeverity;
|
||||||
|
/** 关联节点 ID(如适用) */
|
||||||
|
nodeId?: string;
|
||||||
|
/** 关联节点标题(用于 UI 展示) */
|
||||||
|
nodeTitle?: string;
|
||||||
|
/** i18n 插值参数 */
|
||||||
|
params?: Record<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验结果汇总 */
|
||||||
|
export interface ConsistencyResult {
|
||||||
|
issues: ConsistencyIssue[];
|
||||||
|
/** 统计:目标数 */
|
||||||
|
objectiveCount: number;
|
||||||
|
/** 统计:评价数 */
|
||||||
|
exerciseCount: number;
|
||||||
|
/** 统计:被覆盖的目标数 */
|
||||||
|
coveredObjectiveCount: number;
|
||||||
|
/** 一致性分数(0-100,100 表示完全一致) */
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅取教学节点(排除正文节点) */
|
||||||
|
function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] {
|
||||||
|
return doc.nodes.filter((n): n is LessonPlanNode => n.type !== "textbook_content");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全读取 exercise 节点的知识点 ID 集合 */
|
||||||
|
function getExerciseKpIds(node: LessonPlanNode): Set<string> {
|
||||||
|
if (node.type !== "exercise") return new Set();
|
||||||
|
const data = node.data as ExerciseBlockData;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const item of data.items ?? []) {
|
||||||
|
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
|
||||||
|
for (const kp of item.inlineContent.knowledgePointIds) ids.add(kp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const kp of data.knowledgePointIds ?? []) ids.add(kp);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行目标-评价一致性校验。纯函数,无副作用。
|
||||||
|
*
|
||||||
|
* 当前以"节点"为粒度:objective 节点视为目标单元,exercise 节点视为评价单元。
|
||||||
|
* 只要课案中存在至少一个 exercise,即视为目标已被覆盖(保守判定)。
|
||||||
|
*/
|
||||||
|
export function checkConsistency(doc: LessonPlanDocument): ConsistencyResult {
|
||||||
|
const teachingNodes = getTeachingNodes(doc);
|
||||||
|
const objectiveNodes = teachingNodes.filter((n) => n.type === "objective");
|
||||||
|
const exerciseNodes = teachingNodes.filter((n) => n.type === "exercise");
|
||||||
|
|
||||||
|
const issues: ConsistencyIssue[] = [];
|
||||||
|
|
||||||
|
if (objectiveNodes.length === 0) {
|
||||||
|
issues.push({ code: "noObjective", severity: "warning" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseNodes.length === 0) {
|
||||||
|
issues.push({ code: "noExercise", severity: "warning" });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ex of exerciseNodes) {
|
||||||
|
const kpIds = getExerciseKpIds(ex);
|
||||||
|
if (kpIds.size === 0) {
|
||||||
|
issues.push({
|
||||||
|
code: "exerciseNoKnowledgePoint",
|
||||||
|
severity: "warning",
|
||||||
|
nodeId: ex.id,
|
||||||
|
nodeTitle: ex.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let coveredCount = 0;
|
||||||
|
for (const obj of objectiveNodes) {
|
||||||
|
const isCovered = exerciseNodes.length > 0;
|
||||||
|
if (isCovered) {
|
||||||
|
coveredCount++;
|
||||||
|
} else {
|
||||||
|
issues.push({
|
||||||
|
code: "objectiveNotAssessed",
|
||||||
|
severity: "warning",
|
||||||
|
nodeId: obj.id,
|
||||||
|
nodeTitle: obj.title,
|
||||||
|
params: { title: obj.title || obj.type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalChecks = objectiveNodes.length + exerciseNodes.length;
|
||||||
|
const failedChecks = issues.filter((i) => i.severity === "warning").length;
|
||||||
|
const score = totalChecks === 0 ? 100 : Math.max(0, Math.round(100 - (failedChecks / totalChecks) * 100));
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues,
|
||||||
|
objectiveCount: objectiveNodes.length,
|
||||||
|
exerciseCount: exerciseNodes.length,
|
||||||
|
coveredObjectiveCount: coveredCount,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷函数:是否存在 warning 级别问题 */
|
||||||
|
export function hasConsistencyWarnings(result: ConsistencyResult): boolean {
|
||||||
|
return result.issues.some((i) => i.severity === "warning");
|
||||||
|
}
|
||||||
149
src/modules/lesson-preparation/lib/curriculum-coverage.ts
Normal file
149
src/modules/lesson-preparation/lib/curriculum-coverage.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* V5-20 T4:课标覆盖度统计(教师端课标热力图支撑)。
|
||||||
|
*
|
||||||
|
* 统计教师所有课案对教材知识点的覆盖情况:
|
||||||
|
* - 每个知识点被多少个课案覆盖
|
||||||
|
* - 未被覆盖的知识点(教学盲点)
|
||||||
|
* - 按章节分组的覆盖率
|
||||||
|
*
|
||||||
|
* 纯函数,输入数据由调用方从 data-access 获取。
|
||||||
|
*/
|
||||||
|
import type { KnowledgePoint } from "@/modules/textbooks/types";
|
||||||
|
|
||||||
|
/** 单个知识点的覆盖统计 */
|
||||||
|
export interface KpCoverageStat {
|
||||||
|
kpId: string;
|
||||||
|
kpName: string;
|
||||||
|
chapterId: string | null;
|
||||||
|
/** 覆盖此知识点的课案数量 */
|
||||||
|
planCount: number;
|
||||||
|
/** 覆盖此知识点的课案 ID 列表 */
|
||||||
|
planIds: string[];
|
||||||
|
/** 是否为教学盲点(planCount === 0) */
|
||||||
|
isBlindSpot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 章节覆盖率统计 */
|
||||||
|
export interface ChapterCoverageStat {
|
||||||
|
chapterId: string;
|
||||||
|
/** 该章节下知识点总数 */
|
||||||
|
totalKps: number;
|
||||||
|
/** 被覆盖的知识点数 */
|
||||||
|
coveredKps: number;
|
||||||
|
/** 覆盖率(0-100) */
|
||||||
|
coverageRate: number;
|
||||||
|
/** 章节下的知识点统计 */
|
||||||
|
kps: KpCoverageStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课标热力图统计结果 */
|
||||||
|
export interface CurriculumCoverageResult {
|
||||||
|
/** 按章节分组 */
|
||||||
|
chapters: ChapterCoverageStat[];
|
||||||
|
/** 总知识点数 */
|
||||||
|
totalKps: number;
|
||||||
|
/** 被覆盖的知识点数 */
|
||||||
|
coveredKps: number;
|
||||||
|
/** 整体覆盖率(0-100) */
|
||||||
|
overallCoverageRate: number;
|
||||||
|
/** 教学盲点(未被任何课案覆盖的知识点) */
|
||||||
|
blindSpots: KpCoverageStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课案知识点关联的扁平结构(由调用方从 LessonPlanDocument 提取) */
|
||||||
|
export interface PlanKpLink {
|
||||||
|
planId: string;
|
||||||
|
knowledgePointIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算课标覆盖度热力图数据。纯函数,无副作用。
|
||||||
|
*
|
||||||
|
* @param allKps 教材下所有知识点(由 getKnowledgePointsByTextbookId 获取)
|
||||||
|
* @param planLinks 教师所有课案的知识点关联列表
|
||||||
|
*/
|
||||||
|
export function computeCurriculumCoverage(
|
||||||
|
allKps: KnowledgePoint[],
|
||||||
|
planLinks: PlanKpLink[],
|
||||||
|
): CurriculumCoverageResult {
|
||||||
|
// 构建知识点 → 课案列表 的反向索引
|
||||||
|
const kpToPlans = new Map<string, { planIds: string[]; count: number }>();
|
||||||
|
for (const link of planLinks) {
|
||||||
|
for (const kpId of link.knowledgePointIds) {
|
||||||
|
const existing = kpToPlans.get(kpId);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.planIds.includes(link.planId)) {
|
||||||
|
existing.planIds.push(link.planId);
|
||||||
|
existing.count++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kpToPlans.set(kpId, { planIds: [link.planId], count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按章节分组知识点
|
||||||
|
const chapterMap = new Map<string, KnowledgePoint[]>();
|
||||||
|
for (const kp of allKps) {
|
||||||
|
const chId = kp.chapterId ?? "__no_chapter__";
|
||||||
|
if (!chapterMap.has(chId)) chapterMap.set(chId, []);
|
||||||
|
chapterMap.get(chId)!.push(kp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters: ChapterCoverageStat[] = [];
|
||||||
|
let totalKps = 0;
|
||||||
|
let coveredKps = 0;
|
||||||
|
const blindSpots: KpCoverageStat[] = [];
|
||||||
|
|
||||||
|
for (const [chapterId, kps] of chapterMap) {
|
||||||
|
const kpStats: KpCoverageStat[] = kps.map((kp) => {
|
||||||
|
const coverage = kpToPlans.get(kp.id);
|
||||||
|
const planIds = coverage?.planIds ?? [];
|
||||||
|
const planCount = coverage?.count ?? 0;
|
||||||
|
const stat: KpCoverageStat = {
|
||||||
|
kpId: kp.id,
|
||||||
|
kpName: kp.name,
|
||||||
|
chapterId: kp.chapterId ?? null,
|
||||||
|
planCount,
|
||||||
|
planIds,
|
||||||
|
isBlindSpot: planCount === 0,
|
||||||
|
};
|
||||||
|
if (stat.isBlindSpot) blindSpots.push(stat);
|
||||||
|
return stat;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chapterTotal = kps.length;
|
||||||
|
const chapterCovered = kpStats.filter((s) => !s.isBlindSpot).length;
|
||||||
|
const coverageRate = chapterTotal === 0 ? 0 : Math.round((chapterCovered / chapterTotal) * 100);
|
||||||
|
|
||||||
|
chapters.push({
|
||||||
|
chapterId,
|
||||||
|
totalKps: chapterTotal,
|
||||||
|
coveredKps: chapterCovered,
|
||||||
|
coverageRate,
|
||||||
|
kps: kpStats,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalKps += chapterTotal;
|
||||||
|
coveredKps += chapterCovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallCoverageRate = totalKps === 0 ? 0 : Math.round((coveredKps / totalKps) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chapters,
|
||||||
|
totalKps,
|
||||||
|
coveredKps,
|
||||||
|
overallCoverageRate,
|
||||||
|
blindSpots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据覆盖率返回热力图颜色等级(0-4) */
|
||||||
|
export function getHeatLevel(coverageRate: number): 0 | 1 | 2 | 3 | 4 {
|
||||||
|
if (coverageRate === 0) return 0;
|
||||||
|
if (coverageRate < 25) return 1;
|
||||||
|
if (coverageRate < 50) return 2;
|
||||||
|
if (coverageRate < 75) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
267
src/modules/lesson-preparation/lib/export.ts
Normal file
267
src/modules/lesson-preparation/lib/export.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* V5-4:课案导出/打印工具
|
||||||
|
*
|
||||||
|
* 将画布式 LessonPlanDocument 扁平化为线性教学环节列表,
|
||||||
|
* 供打印视图(print-view.tsx)渲染。支持详细版/简洁版两种模式:
|
||||||
|
* - detailed: 包含所有 11 种 Block
|
||||||
|
* - concise: 仅包含 objective / new_teaching / exercise / homework
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BlackboardBlockData,
|
||||||
|
BlockData,
|
||||||
|
ExerciseBlockData,
|
||||||
|
HomeworkBlockData,
|
||||||
|
ImportBlockData,
|
||||||
|
KeyPointBlockData,
|
||||||
|
LessonPlan,
|
||||||
|
LessonPlanDocument,
|
||||||
|
NewTeachingBlockData,
|
||||||
|
ObjectiveBlockData,
|
||||||
|
ReflectionBlockData,
|
||||||
|
RichTextBlockData,
|
||||||
|
SummaryBlockData,
|
||||||
|
TextStudyBlockData,
|
||||||
|
TextbookContentNode,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** 导出版本 */
|
||||||
|
export type ExportVariant = "detailed" | "concise";
|
||||||
|
|
||||||
|
/** 简洁版包含的 Block 类型 */
|
||||||
|
const CONCISE_BLOCK_TYPES = new Set([
|
||||||
|
"objective",
|
||||||
|
"new_teaching",
|
||||||
|
"exercise",
|
||||||
|
"homework",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 扁平化后的教学环节 */
|
||||||
|
export interface PrintableSection {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
/** 已扁平化为字符串数组的内容 */
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出元信息(页眉/页脚用) */
|
||||||
|
export interface ExportMeta {
|
||||||
|
planTitle: string;
|
||||||
|
textbookTitle?: string;
|
||||||
|
chapterTitle?: string;
|
||||||
|
teacherName?: string;
|
||||||
|
className?: string;
|
||||||
|
/** 备课最后保存时间 ISO */
|
||||||
|
lastSavedAt?: string;
|
||||||
|
/** 教学时长(分钟),来自 import 节点 durationMin 求和 */
|
||||||
|
totalDurationMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出文档 */
|
||||||
|
export interface PrintableLessonPlan {
|
||||||
|
meta: ExportMeta;
|
||||||
|
/** 课文正文(如有 textbook_content 节点) */
|
||||||
|
textbookContent: string | null;
|
||||||
|
/** 教学环节列表(按 order 排序) */
|
||||||
|
sections: PrintableSection[];
|
||||||
|
variant: ExportVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将画布式文档扁平化为可打印的线性结构。
|
||||||
|
*
|
||||||
|
* @param plan 课案对象
|
||||||
|
* @param meta 元信息(教师名、班级等由调用方注入)
|
||||||
|
* @param variant detailed | concise
|
||||||
|
*/
|
||||||
|
export function flattenLessonPlanForPrint(
|
||||||
|
plan: LessonPlan,
|
||||||
|
meta: Partial<ExportMeta>,
|
||||||
|
variant: ExportVariant = "detailed",
|
||||||
|
): PrintableLessonPlan {
|
||||||
|
const doc: LessonPlanDocument = plan.content;
|
||||||
|
const textbookContent = extractTextbookContent(doc);
|
||||||
|
const teachingNodes = doc.nodes
|
||||||
|
.filter((n) => n.type !== "textbook_content")
|
||||||
|
.filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type))
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const sections = teachingNodes.map((node) =>
|
||||||
|
flattenBlock(node.type, node.title, node.data as BlockData),
|
||||||
|
);
|
||||||
|
|
||||||
|
// V5-4:教学时长由 import 节点求和
|
||||||
|
const totalDurationMin = doc.nodes
|
||||||
|
.filter((n) => n.type === "import")
|
||||||
|
.reduce((sum, n) => {
|
||||||
|
const data = n.data as ImportBlockData;
|
||||||
|
return sum + (data.durationMin ?? 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
planTitle: plan.title,
|
||||||
|
lastSavedAt: plan.lastSavedAt ?? undefined,
|
||||||
|
totalDurationMin,
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
textbookContent,
|
||||||
|
sections,
|
||||||
|
variant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextbookContent(doc: LessonPlanDocument): string | null {
|
||||||
|
const node = doc.nodes.find(
|
||||||
|
(n): n is TextbookContentNode => n.type === "textbook_content",
|
||||||
|
);
|
||||||
|
if (!node) return null;
|
||||||
|
return node.data.content || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenBlock(
|
||||||
|
type: string,
|
||||||
|
title: string,
|
||||||
|
data: BlockData,
|
||||||
|
): PrintableSection {
|
||||||
|
const lines = flattenBlockData(type, data);
|
||||||
|
return { type, title, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenBlockData(type: string, data: BlockData): string[] {
|
||||||
|
switch (type) {
|
||||||
|
case "objective":
|
||||||
|
return flattenObjective(data as ObjectiveBlockData);
|
||||||
|
case "key_point":
|
||||||
|
return flattenKeyPoint(data as KeyPointBlockData);
|
||||||
|
case "import":
|
||||||
|
return flattenImport(data as ImportBlockData);
|
||||||
|
case "new_teaching":
|
||||||
|
return flattenNewTeaching(data as NewTeachingBlockData);
|
||||||
|
case "summary":
|
||||||
|
return flattenSummary(data as SummaryBlockData);
|
||||||
|
case "homework":
|
||||||
|
return flattenHomework(data as HomeworkBlockData);
|
||||||
|
case "blackboard":
|
||||||
|
return flattenBlackboard(data as BlackboardBlockData);
|
||||||
|
case "reflection":
|
||||||
|
return flattenReflection(data as ReflectionBlockData);
|
||||||
|
case "exercise":
|
||||||
|
return flattenExercise(data as ExerciseBlockData);
|
||||||
|
case "text_study":
|
||||||
|
return flattenTextStudy(data as TextStudyBlockData);
|
||||||
|
case "rich_text":
|
||||||
|
return flattenRichText(data as RichTextBlockData);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenObjective(data: ObjectiveBlockData): string[] {
|
||||||
|
const dimensionLabel: Record<ObjectiveItem["dimension"], string> = {
|
||||||
|
knowledge: "知识与技能",
|
||||||
|
process: "过程与方法",
|
||||||
|
emotion: "情感态度",
|
||||||
|
};
|
||||||
|
return data.objectives.map(
|
||||||
|
(o) => `[${dimensionLabel[o.dimension]}] ${o.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenKeyPoint(data: KeyPointBlockData): string[] {
|
||||||
|
return data.keyPoints.map((kp) =>
|
||||||
|
kp.type === "key" ? `[重点] ${kp.text}` : `[难点] ${kp.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenImport(data: ImportBlockData): string[] {
|
||||||
|
const methodLabel: Record<ImportBlockData["method"], string> = {
|
||||||
|
question: "提问导入",
|
||||||
|
situation: "情境导入",
|
||||||
|
review: "复习导入",
|
||||||
|
other: "其他",
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
`方式:${methodLabel[data.method]}`,
|
||||||
|
`时长:${data.durationMin} 分钟`,
|
||||||
|
data.prompt ? `导入语:${data.prompt}` : "",
|
||||||
|
].filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenNewTeaching(data: NewTeachingBlockData): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
data.teachingPoints.forEach((p, i) => {
|
||||||
|
lines.push(`步骤 ${i + 1}:`);
|
||||||
|
if (p.outline) lines.push(` 提纲:${p.outline}`);
|
||||||
|
if (p.boardNotes) lines.push(` 板书要点:${p.boardNotes}`);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSummary(data: SummaryBlockData): string[] {
|
||||||
|
const lines = data.summaryPoints.map((p, i) => `${i + 1}. ${p}`);
|
||||||
|
if (data.homeworkPreview) lines.push(`作业预览:${data.homeworkPreview}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenHomework(data: HomeworkBlockData): string[] {
|
||||||
|
const typeLabel: Record<HomeworkAssignment["type"], string> = {
|
||||||
|
exercise: "练习",
|
||||||
|
reading: "阅读",
|
||||||
|
writing: "写作",
|
||||||
|
};
|
||||||
|
return data.assignments.map(
|
||||||
|
(a) => `[${typeLabel[a.type]}] ${a.description}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenBlackboard(data: BlackboardBlockData): string[] {
|
||||||
|
const layoutLabel: Record<BlackboardBlockData["layout"], string> = {
|
||||||
|
structure: "结构式",
|
||||||
|
mindmap: "思维导图",
|
||||||
|
text: "文字式",
|
||||||
|
};
|
||||||
|
return [`形式:${layoutLabel[data.layout]}`, data.content].filter(
|
||||||
|
(s) => s.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenReflection(data: ReflectionBlockData): string[] {
|
||||||
|
const aspectLabel: Record<ReflectionItem["aspect"], string> = {
|
||||||
|
effectiveness: "教学效果",
|
||||||
|
problems: "存在问题",
|
||||||
|
improvements: "改进措施",
|
||||||
|
};
|
||||||
|
return data.reflection.map(
|
||||||
|
(r) => `[${aspectLabel[r.aspect]}] ${r.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenExercise(data: ExerciseBlockData): string[] {
|
||||||
|
if (data.items.length === 0) return ["(无题目)"];
|
||||||
|
return data.items.map((item, i) => {
|
||||||
|
const source = item.source === "inline" ? "课案内新建" : "题库";
|
||||||
|
return `${i + 1}. [${source}] 题目 ID: ${item.questionId} (${item.score} 分)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTextStudy(data: TextStudyBlockData): string[] {
|
||||||
|
if (data.annotations.length === 0) return ["(无文本研习标注)"];
|
||||||
|
return data.annotations.map(
|
||||||
|
(a, i) => `${i + 1}. [${a.title}] ${a.note}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRichText(data: RichTextBlockData): string[] {
|
||||||
|
// HTML 简易去标签,仅保留文本(打印友好)
|
||||||
|
const text = data.html
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
|
return text.length > 0 ? [text] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅用于类型推导的本地导入别名,避免在 switch case 中重复 import
|
||||||
|
type ObjectiveItem = ObjectiveBlockData["objectives"][number];
|
||||||
|
type HomeworkAssignment = HomeworkBlockData["assignments"][number];
|
||||||
|
type ReflectionItem = ReflectionBlockData["reflection"][number];
|
||||||
127
src/modules/lesson-preparation/lib/version-diff.ts
Normal file
127
src/modules/lesson-preparation/lib/version-diff.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* V5-16 T2:版本对比工具(反思闭环支撑)。
|
||||||
|
*
|
||||||
|
* 对比两个 LessonPlanDocument,输出节点级别的差异:
|
||||||
|
* - added: 新版本中新增的节点
|
||||||
|
* - removed: 旧版本中有但新版本中删除的节点
|
||||||
|
* - modified: 两版本都有但内容(title/data/stage/differentiation)变化的节点
|
||||||
|
* - unchanged: 相同的节点
|
||||||
|
*
|
||||||
|
* 纯函数,无副作用,便于单测。
|
||||||
|
*/
|
||||||
|
import type { LessonPlanDocument, LessonPlanNode } from "../types";
|
||||||
|
|
||||||
|
/** 差异类型 */
|
||||||
|
export type DiffChangeType = "added" | "removed" | "modified" | "unchanged";
|
||||||
|
|
||||||
|
/** 单个节点的差异 */
|
||||||
|
export interface NodeDiff {
|
||||||
|
type: DiffChangeType;
|
||||||
|
/** 新版本中的节点(added/modified/unchanged 时存在) */
|
||||||
|
newNode?: LessonPlanNode;
|
||||||
|
/** 旧版本中的节点(removed/modified/unchanged 时存在) */
|
||||||
|
oldNode?: LessonPlanNode;
|
||||||
|
/** modified 时的字段级变更列表 */
|
||||||
|
changedFields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文档对比结果 */
|
||||||
|
export interface VersionDiffResult {
|
||||||
|
diffs: NodeDiff[];
|
||||||
|
/** 统计 */
|
||||||
|
summary: {
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
modified: number;
|
||||||
|
unchanged: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全序列化节点数据用于比较(忽略 position 等非内容字段) */
|
||||||
|
function nodeContentKey(n: LessonPlanNode): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
title: n.title,
|
||||||
|
data: n.data,
|
||||||
|
stage: n.stage,
|
||||||
|
differentiation: n.differentiation,
|
||||||
|
type: n.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两个课案文档,返回节点级差异。
|
||||||
|
*
|
||||||
|
* @param oldDoc 旧版本文档
|
||||||
|
* @param newDoc 新版本文档
|
||||||
|
*/
|
||||||
|
export function diffDocuments(
|
||||||
|
oldDoc: LessonPlanDocument,
|
||||||
|
newDoc: LessonPlanDocument,
|
||||||
|
): VersionDiffResult {
|
||||||
|
const oldNodes = new Map<string, LessonPlanNode>();
|
||||||
|
const newNodes = new Map<string, LessonPlanNode>();
|
||||||
|
|
||||||
|
for (const n of oldDoc.nodes) {
|
||||||
|
if (n.type !== "textbook_content") oldNodes.set(n.id, n);
|
||||||
|
}
|
||||||
|
for (const n of newDoc.nodes) {
|
||||||
|
if (n.type !== "textbook_content") newNodes.set(n.id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffs: NodeDiff[] = [];
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
let modified = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
|
||||||
|
// 遍历新版本节点
|
||||||
|
for (const [id, newNode] of newNodes) {
|
||||||
|
const oldNode = oldNodes.get(id);
|
||||||
|
if (!oldNode) {
|
||||||
|
diffs.push({ type: "added", newNode });
|
||||||
|
added++;
|
||||||
|
} else {
|
||||||
|
const oldKey = nodeContentKey(oldNode);
|
||||||
|
const newKey = nodeContentKey(newNode);
|
||||||
|
if (oldKey === newKey) {
|
||||||
|
diffs.push({ type: "unchanged", oldNode, newNode });
|
||||||
|
unchanged++;
|
||||||
|
} else {
|
||||||
|
const changedFields: string[] = [];
|
||||||
|
if (oldNode.title !== newNode.title) changedFields.push("title");
|
||||||
|
if (oldNode.stage !== newNode.stage) changedFields.push("stage");
|
||||||
|
if (oldNode.differentiation !== newNode.differentiation) changedFields.push("differentiation");
|
||||||
|
if (JSON.stringify(oldNode.data) !== JSON.stringify(newNode.data)) changedFields.push("data");
|
||||||
|
diffs.push({ type: "modified", oldNode, newNode, changedFields });
|
||||||
|
modified++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历旧版本中已删除的节点
|
||||||
|
for (const [id, oldNode] of oldNodes) {
|
||||||
|
if (!newNodes.has(id)) {
|
||||||
|
diffs.push({ type: "removed", oldNode });
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:added/removed/modified 优先,unchanged 靠后
|
||||||
|
const order: Record<DiffChangeType, number> = {
|
||||||
|
removed: 0,
|
||||||
|
added: 1,
|
||||||
|
modified: 2,
|
||||||
|
unchanged: 3,
|
||||||
|
};
|
||||||
|
diffs.sort((a, b) => order[a.type] - order[b.type]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
diffs,
|
||||||
|
summary: { added, removed, modified, unchanged },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷函数:是否有实际变更 */
|
||||||
|
export function hasChanges(result: VersionDiffResult): boolean {
|
||||||
|
return result.summary.added > 0 || result.summary.removed > 0 || result.summary.modified > 0;
|
||||||
|
}
|
||||||
@@ -171,6 +171,50 @@ export interface LessonPlanDataService {
|
|||||||
message?: string;
|
message?: string;
|
||||||
errors?: Record<string, string[]>;
|
errors?: Record<string, string[]>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// ---- V5-5:M6 附件库 UI 集成 ----
|
||||||
|
|
||||||
|
/** 查询课案的所有附件(attachment-picker 使用)*/
|
||||||
|
getLessonPlanAttachments(planId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { items: LessonPlanAttachmentOption[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 创建附件记录(上传完成后调用,关联到课案)*/
|
||||||
|
createLessonPlanAttachment(input: {
|
||||||
|
planId: string;
|
||||||
|
blockId?: string;
|
||||||
|
fileId: string;
|
||||||
|
displayName: string;
|
||||||
|
attachmentType?: "reference" | "material" | "supplementary";
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { attachment: LessonPlanAttachmentOption | null };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 删除附件 */
|
||||||
|
deleteLessonPlanAttachment(attachmentId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-5:附件选项(供 picker 渲染)*/
|
||||||
|
export interface LessonPlanAttachmentOption {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
blockId?: string;
|
||||||
|
fileId: string;
|
||||||
|
displayName: string;
|
||||||
|
attachmentType: "reference" | "material" | "supplementary";
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** V5-6 修复:MIME 类型,供 kindIcon 推断渲染方式 */
|
||||||
|
mimeType?: string;
|
||||||
|
/** V5-6 修复:渲染用 URL */
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||||
|
import {
|
||||||
|
getLessonPlanAttachmentsAction,
|
||||||
|
createLessonPlanAttachmentAction,
|
||||||
|
deleteLessonPlanAttachmentAction,
|
||||||
|
} from "../actions-attachments";
|
||||||
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,5 +153,51 @@ export function createDefaultDataService(): LessonPlanDataService {
|
|||||||
}
|
}
|
||||||
return { success: false, message: res.message, errors: res.errors };
|
return { success: false, message: res.message, errors: res.errors };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// V5-5:M6 附件库 UI 集成
|
||||||
|
async getLessonPlanAttachments(planId) {
|
||||||
|
const res = await getLessonPlanAttachmentsAction(planId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
// 将 DB 层的 Date 转为 ISO string 以匹配 LessonPlanAttachmentOption 类型
|
||||||
|
const items = res.data.items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
planId: a.planId,
|
||||||
|
blockId: a.blockId ?? undefined,
|
||||||
|
fileId: a.fileId,
|
||||||
|
displayName: a.displayName,
|
||||||
|
attachmentType: a.attachmentType,
|
||||||
|
uploadedBy: a.uploadedBy,
|
||||||
|
createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt,
|
||||||
|
}));
|
||||||
|
return { success: true, data: { items } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLessonPlanAttachment(input) {
|
||||||
|
const res = await createLessonPlanAttachmentAction(input);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const a = res.data.attachment;
|
||||||
|
const attachment = a
|
||||||
|
? {
|
||||||
|
id: a.id,
|
||||||
|
planId: a.planId,
|
||||||
|
blockId: a.blockId ?? undefined,
|
||||||
|
fileId: a.fileId,
|
||||||
|
displayName: a.displayName,
|
||||||
|
attachmentType: a.attachmentType,
|
||||||
|
uploadedBy: a.uploadedBy,
|
||||||
|
createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return { success: true, data: { attachment } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLessonPlanAttachment(attachmentId) {
|
||||||
|
const res = await deleteLessonPlanAttachmentAction(attachmentId);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ export type LessonNodeType = BlockType | TextbookContentNodeType;
|
|||||||
export interface RichTextBlockData {
|
export interface RichTextBlockData {
|
||||||
html: string;
|
html: string;
|
||||||
knowledgePointIds: string[];
|
knowledgePointIds: string[];
|
||||||
|
/**
|
||||||
|
* V5-5:节点内嵌入的多媒体附件引用。
|
||||||
|
* 字段 attachmentId 关联 lessonPlanAttachments 表;url 为渲染用相对/绝对地址;
|
||||||
|
* kind 决定 UI 渲染(image/audio/video/file)。
|
||||||
|
* 旧数据无此字段时按空数组处理(document-migration 兼容)。
|
||||||
|
*/
|
||||||
|
attachments?: RichTextAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** V5-5:富文本节点内的附件引用 */
|
||||||
|
export interface RichTextAttachment {
|
||||||
|
attachmentId: string;
|
||||||
|
fileId: string;
|
||||||
|
displayName: string;
|
||||||
|
/** MIME 大类,决定 UI 渲染方式 */
|
||||||
|
kind: "image" | "audio" | "video" | "file";
|
||||||
|
/** 渲染用 URL(由 /api/upload 返回的 url) */
|
||||||
|
url: string;
|
||||||
|
/** MIME 类型(如 image/png),用于 <source> 标签 */
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文本研习
|
// 文本研习
|
||||||
@@ -209,6 +229,26 @@ export type BlockData =
|
|||||||
| BlackboardBlockData
|
| BlackboardBlockData
|
||||||
| ReflectionBlockData;
|
| ReflectionBlockData;
|
||||||
|
|
||||||
|
// V5-15 T1:教学阶段(用于画布节点分组与可视化排序)
|
||||||
|
// - import: 导入环节
|
||||||
|
// - new_teaching: 新授环节
|
||||||
|
// - consolidation: 巩固练习
|
||||||
|
// - summary: 总结提升
|
||||||
|
// 未设置(undefined)表示教师未显式归类
|
||||||
|
export type TeachingStage = "import" | "new_teaching" | "consolidation" | "summary";
|
||||||
|
|
||||||
|
// V5-15 i18n 键后缀(与 TeachingStage 一一对应)
|
||||||
|
export const TEACHING_STAGE_KEYS = ["import", "new_teaching", "consolidation", "summary"] as const;
|
||||||
|
|
||||||
|
// V5-18 W6:差异化教学标记(按学生水平分组)
|
||||||
|
// - basic: 基础(全员必做)
|
||||||
|
// - intermediate: 提高(多数学生)
|
||||||
|
// - advanced: 拓展(学有余力)
|
||||||
|
export type DifferentiationLevel = "basic" | "intermediate" | "advanced";
|
||||||
|
|
||||||
|
// V5-18 i18n 键后缀
|
||||||
|
export const DIFFERENTIATION_LEVEL_KEYS = ["basic", "intermediate", "advanced"] as const;
|
||||||
|
|
||||||
// Block 联合
|
// Block 联合
|
||||||
export interface Block {
|
export interface Block {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -216,6 +256,10 @@ export interface Block {
|
|||||||
title: string;
|
title: string;
|
||||||
data: BlockData;
|
data: BlockData;
|
||||||
order: number;
|
order: number;
|
||||||
|
/** V5-15 T1:教学阶段分组(可选,教师可显式归类节点)*/
|
||||||
|
stage?: TeachingStage;
|
||||||
|
/** V5-18 W6:差异化教学标记(可选,标注此节点的目标学生水平)*/
|
||||||
|
differentiation?: DifferentiationLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 教学节点(Block + 画布坐标)
|
// 教学节点(Block + 画布坐标)
|
||||||
|
|||||||
Reference in New Issue
Block a user