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:
SpecialX
2026-07-04 10:22:10 +08:00
parent 41fe8d8903
commit 25dca843be
45 changed files with 5295 additions and 210 deletions

View File

@@ -3,9 +3,20 @@
import { requirePermission } from "@/shared/lib/auth-guard";
import { handleActionError } from "@/shared/lib/action-utils";
import { Permissions } from "@/shared/types/permissions";
import { getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { suggestKnowledgePoints } from "./ai-suggest";
import { suggestKnowledgePointsSchema } from "./schema";
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";
export async function suggestKnowledgePointsAction(input: {
@@ -45,3 +56,99 @@ export async function suggestKnowledgePointsAction(input: {
return handleActionError(e);
}
}
/**
* V5-17 A1/A2AI 反馈闭环。
* 调用 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 A3AI 差异化教学建议生成。
* 根据课案内容生成基础/提高/拓展三个层次的教学建议。
*/
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 A4AI 课标实时核对。
* 检查课案是否覆盖教材课标要求。
*/
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 A5AI 可解释评估。
* 对课案中的练习节点给出可解释的评分依据。
*/
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);
}
}

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

View File

@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/shared/lib/auth-guard";
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<
ActionState<{

View File

@@ -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/A5AI 差异化对话框。
*
* 三个 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>;
}

View 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/A2AI 反馈闭环对话框(含解释性展示) */
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;
}

View 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-5ESC 关闭
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>
);
}

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Tag } from "lucide-react";
import { Tag, Eye, Pencil } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { isBlackboardLayout } from "../../lib/type-guards";
import { KnowledgePointPicker } from "../knowledge-point-picker";
@@ -17,47 +17,98 @@ interface Props {
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
/**
* V5-14 F4板书可视化工具。
*
* 在原有纯文本编辑基础上增加轻量级可视化预览(不引入新库):
* - structure结构式按行解析缩进渲染为带连接线的层级树
* - mindmap思维导图第一行为中心其余为分支节点
* - text文字式等宽字体直接展示
*
* 编辑/预览模式切换,避免双栏占满侧边面板。
*/
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("blackboard.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) => {
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) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
<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")}
/>
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) => {
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) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
{/* 编辑/预览切换 */}
<div className="flex border border-outline-variant rounded overflow-hidden self-end">
<button
type="button"
onClick={() => setPreviewMode(false)}
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.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>
{/* 内容区:编辑模式 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">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
@@ -86,3 +137,116 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props
</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() };
});
}

View File

@@ -170,6 +170,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
planId={planId}
blockId={blockId}
classes={classes}
items={data.items}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>

View File

@@ -3,18 +3,24 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData } from "../../types";
import type { RichTextBlockData, RichTextAttachment } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { AttachmentPicker } from "../attachment-picker";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Tag } from "lucide-react";
import { Tag, Paperclip, Trash2 } from "lucide-react";
interface Props {
data: RichTextBlockData;
hint?: string;
textbookId?: string;
chapterId?: string;
/** V5-5当前课案 ID用于附件库 picker */
planId?: string;
/** V5-5当前 block ID用于附件关联 */
blockId?: string;
onUpdate: (data: RichTextBlockData) => void;
}
@@ -23,6 +29,8 @@ export function RichTextBlock({
hint,
textbookId,
chapterId,
planId,
blockId,
onUpdate,
}: Props) {
const t = useTranslations("lessonPreparation");
@@ -30,6 +38,14 @@ export function RichTextBlock({
extensions: [
StarterKit,
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,
immediatelyRender: false,
@@ -52,6 +68,33 @@ export function RichTextBlock({
}, [data.html, editor]);
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 (
<div>
@@ -69,7 +112,79 @@ export function RichTextBlock({
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</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>
{/* 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 && (
<LessonPlanErrorBoundary>
<KnowledgePointPicker
@@ -81,6 +196,18 @@ export function RichTextBlock({
/>
</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>
);
}

View File

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

View 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];
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
@@ -24,8 +24,14 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} 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 { 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 {
planId: string;
@@ -75,6 +81,11 @@ export function LessonPlanEditor({
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = 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-17AI 反馈对话框
const [showAiDifferentiation, setShowAiDifferentiation] = useState(false); // V5-21AI 差异化对话框
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -90,9 +101,11 @@ export function LessonPlanEditor({
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
// V3 修复:完全通过 service 调用,不直接 import actions
// V5-1 修复:保存失败显示 toast + 设置 saveError断网时不触发保存
useEffect(() => {
if (!editor.isDirty) return;
if (!service) return;
if (!editor.isOnline) return; // V5-1断网期间不触发保存请求
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
@@ -103,9 +116,18 @@ export function LessonPlanEditor({
title: state.title,
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) {
console.error("[LessonPlanEditor] auto-save failed", e);
state.setSaveError(true);
toast.error(t("status.saveFailed"), { description: t("status.saveFailedHint") });
} finally {
state.setSaving(false);
}
@@ -113,7 +135,98 @@ export function LessonPlanEditor({
return () => {
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
useEffect(() => {
@@ -266,6 +379,25 @@ export function LessonPlanEditor({
? t("status.unsaved")
: t("status.saved")}
</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
variant="outline"
size="sm"
@@ -273,9 +405,77 @@ export function LessonPlanEditor({
>
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</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}>
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</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/A2AI 反馈按钮 */}
<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/A5AI 差异化与课标核对按钮 */}
<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 修复)*/}
{planStatus === "published" ? (
<AlertDialog>
@@ -362,6 +562,7 @@ export function LessonPlanEditor({
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
planId={planId}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
@@ -377,8 +578,63 @@ export function LessonPlanEditor({
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
currentDoc={editor.doc}
/>
</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/A2AI 反馈对话框 */}
{showAiFeedback && (
<LessonPlanErrorBoundary>
<AiFeedbackDialog
doc={editor.doc}
onClose={() => setShowAiFeedback(false)}
/>
</LessonPlanErrorBoundary>
)}
{/* V5-21 A3/A4/A5AI 差异化与课标核对对话框 */}
{showAiDifferentiation && (
<LessonPlanErrorBoundary>
<AiDifferentiationDialog
doc={editor.doc}
textbookId={textbookId}
onClose={() => setShowAiDifferentiation(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}

View File

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

View File

@@ -15,6 +15,8 @@ import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
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";
const nodeTypes = {
@@ -39,6 +41,8 @@ interface Props {
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
const t = useTranslations("lessonPreparation");
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 rfEdges = useMemo(
@@ -65,6 +69,13 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
});
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
// V5-13 P3移动端渲染线性视图
if (isMobile) {
return (
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
);
}
return (
<div className="h-full w-full relative">
{/* 顶部信息条 */}

View File

@@ -9,6 +9,8 @@ import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
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 类型。
@@ -27,11 +29,13 @@ interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** V5-5当前课案 ID透传给 RichTextBlock 用于素材库 picker */
planId?: string;
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
export function NodeEditPanel({ textbookId, chapterId, classes, planId, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
@@ -156,6 +160,55 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
</Button>
</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 配置驱动渲染 */}
<div className="flex-1 overflow-y-auto p-3">
<LessonPlanErrorBoundary>
@@ -166,6 +219,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerat
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
planId={planId}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}

View File

@@ -14,8 +14,11 @@ import {
type Connection,
applyEdgeChanges,
BackgroundVariant,
Panel,
} from "@xyflow/react";
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 { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
@@ -43,8 +46,14 @@ export function NodeEditor({}: Props) {
setEdges,
addAnchor,
addNode,
autoLayout,
} = useLessonPlanEditor();
// V5-8自动布局按钮
const handleAutoLayout = useCallback(() => {
autoLayout("TB");
}, [autoLayout]);
// P1-1构建可锚定的教学节点列表排除正文节点
const anchorableNodes = useMemo(
() =>
@@ -233,6 +242,10 @@ export function NodeEditor({}: Props) {
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
onlyRenderVisibleElements
minZoom={0.2}
maxZoom={2.5}
elevateNodesOnSelect={false}
>
<Background
variant={BackgroundVariant.Dots}
@@ -241,6 +254,20 @@ export function NodeEditor({}: Props) {
color="#ccc"
/>
<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
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {

View 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-4esc 关闭
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>
);
}

View File

@@ -1,24 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
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 {
planId: string;
blockId: string;
classes: { id: string; name: string }[];
/** V5-3题目列表从 exercise-block 传入,用于预览步骤)*/
items: ExerciseItem[];
onClose: () => 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({
planId,
blockId,
classes,
items,
onClose,
onPublished,
}: Props) {
@@ -26,6 +49,7 @@ export function PublishHomeworkDialog({
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const tracker = useLessonPlanTrackerSafe();
const [step, setStep] = useState<Step>("select");
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const [availableAt, setAvailableAt] = useState("");
const [dueAt, setDueAt] = useState("");
@@ -41,12 +65,44 @@ export function PublishHomeworkDialog({
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function handlePublish() {
if (!service) return;
// V5-3计算总分
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) {
setError(t("publish.selectClass"));
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);
setError(null);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
role="dialog"
aria-modal="true"
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">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("publish.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 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 className="flex justify-between items-center p-4 border-b">
<div className="flex items-center gap-2">
<h3 className="font-title-md">{t("publish.title")}</h3>
<span className="text-xs text-on-surface-variant">
{t("publish.stepIndicator", { current: stepNumber, total: 3, label: stepLabel })}
</span>
</div>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</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 className="p-4 space-y-3 overflow-y-auto flex-1">
{/* 步骤 1选班级 + 时间 */}
{step === "select" && (
<>
<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={() => 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>
<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 className="p-4 border-t flex justify-between gap-2">
{step === "select" && (
<>
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<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>
{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>
</div>
</div>

View File

@@ -8,10 +8,11 @@ import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
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 type { ExerciseItem } from "../types"
import type { QuestionType } from "@/modules/questions/types"
import { isRecord } from "@/shared/lib/type-guards"
// 类型守卫:验证字符串是否为有效的 QuestionType避免 as 断言)
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
@@ -155,20 +204,90 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</p>
) : (
<div className="space-y-2">
{questions.map((q) => (
<div
key={q.id}
className="border rounded p-2 flex justify-between items-center"
>
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
<span className="text-xs text-on-surface-variant mr-2">
{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>
))}
{questions.map((q) => {
const isExpanded = expandedId === q.id
const stem = extractStem(q.content)
const options = extractOptions(q.content)
const answer = extractAnswer(q.content)
return (
<div
key={q.id}
className="border rounded p-2"
>
<div className="flex justify-between items-center">
<button
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>

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

View File

@@ -9,9 +9,39 @@ import type { TextbookPickerOption, ChapterPickerOption } from "../providers/les
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
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";
/** V5-9localStorage 中最近使用教材的存储键 */
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() {
const t = useTranslations("lessonPreparation");
const router = useRouter();
@@ -33,6 +63,42 @@ export function TemplatePicker() {
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
// P1-6个人模板
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;
@@ -176,24 +242,70 @@ export function TemplatePicker() {
) : textbooks.length === 0 ? (
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
) : (
<select
value={textbookId}
onChange={(e) => {
setTextbookId(e.target.value);
setChapterId("");
}}
required
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
>
<option value="">{t("picker.selectTextbook")}</option>
{textbooks.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.title}
{tb.subject ? ` · ${tb.subject}` : ""}
{tb.grade ? ` · ${tb.grade}` : ""}
</option>
))}
</select>
<div className="space-y-2">
{/* V5-9搜索框 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-on-surface-variant" aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("picker.searchTextbookPlaceholder")}
className="w-full border border-outline-variant rounded-lg pl-8 pr-3 py-2 bg-surface text-sm"
aria-label={t("picker.searchTextbookLabel")}
/>
</div>
{/* V5-9最近使用教材无搜索词时显示 */}
{!searchQuery.trim() && recentTextbooks.length > 0 && (
<div className="space-y-1">
<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>

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

View File

@@ -19,13 +19,16 @@ import {
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
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 {
open: boolean;
onClose: () => void;
planId: string;
onReverted: () => void;
/** V5-16当前文档用于版本对比 */
currentDoc?: LessonPlanDocument;
}
export function VersionHistoryDrawer({
@@ -33,6 +36,7 @@ export function VersionHistoryDrawer({
onClose,
planId,
onReverted,
currentDoc,
}: Props) {
const t = useTranslations("lessonPreparation");
const ctx = useLessonPlanContextSafe();
@@ -40,6 +44,8 @@ export function VersionHistoryDrawer({
const tracker = useLessonPlanTrackerSafe();
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
const [loading, setLoading] = useState(false);
/** V5-16当前正在对比的版本null 表示显示列表) */
const [comparingVersion, setComparingVersion] = useState<LessonPlanVersion | null>(null);
// P1-1 修复ESC 键关闭抽屉open 时才监听)
useEffect(() => {
@@ -97,56 +103,93 @@ export function VersionHistoryDrawer({
<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">
<FocusTrap className="contents">
<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"
{/* V5-16版本对比视图当 comparingVersion 存在时显示) */}
{comparingVersion && currentDoc ? (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-headline-md text-headline-md">
{t("diff.comparing", { version: comparingVersion.versionNo })}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setComparingVersion(null)}
>
<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>
<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>
))}
{t("diff.back")}
</Button>
</div>
<VersionDiffView
oldDoc={comparingVersion.content}
newDoc={currentDoc}
oldVersionNo={comparingVersion.versionNo}
/>
</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>
</div>

View File

@@ -41,6 +41,8 @@ export interface BlockRenderProps {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** V5-5当前课案 ID仅 RichTextBlock 用于素材库 picker 关联) */
planId?: string;
onUpdate: (data: BlockData) => void;
}
@@ -190,6 +192,8 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
data={rest.data}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
planId={rest.planId}
blockId={rest.blockId}
onUpdate={(d) => rest.onUpdate(d)}
/>
);

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

View File

@@ -14,6 +14,7 @@ import type {
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
import { computeAutoLayout } from "../lib/auto-layout";
import type { EditorState } from "./use-lesson-plan-editor";
export interface EditorSlice {
@@ -40,6 +41,8 @@ export interface EditorSlice {
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
/** V5-8自动布局使用 dagre 计算并应用新位置 */
autoLayout: (direction?: "TB" | "LR") => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
@@ -62,12 +65,16 @@ export const createEditorSlice: StateCreator<
anchors: [],
},
setTitle: (title) => set({ title, isDirty: true }),
setTitle: (title) => {
get().pushHistory();
set({ title, isDirty: true });
},
setPlanId: (planId) => set({ planId }),
addNode: (type, position, title) => {
const id = createId();
const state = get();
state.pushHistory(); // V5-2撤销/重做
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
@@ -91,7 +98,8 @@ export const createEditorSlice: StateCreator<
return id;
},
updateNode: (id, patch) =>
updateNode: (id, patch) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -104,9 +112,12 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
})),
}));
},
updateNodePosition: (id, position) =>
updateNodePosition: (id, position) => {
// V5-2 注:拖拽过程中会产生大量 position 更新,仅在拖拽开始时推一次 history。
// 调用方应在 onDragStart 时调用 pushHistory此处不重复推入。
set((s) => ({
doc: {
...s.doc,
@@ -119,9 +130,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
})),
}));
},
removeNode: (id) =>
removeNode: (id) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
@@ -146,9 +159,11 @@ export const createEditorSlice: StateCreator<
isDirty: true,
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
});
},
updateTextbookContent: (data) =>
updateTextbookContent: (data) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -159,7 +174,8 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
})),
}));
},
getTextbookContentNode: () => {
const state = get();
@@ -171,6 +187,7 @@ export const createEditorSlice: StateCreator<
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
state.pushHistory(); // V5-2撤销/重做
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
@@ -202,7 +219,8 @@ export const createEditorSlice: StateCreator<
return anchorId;
},
removeAnchor: (anchorId) =>
removeAnchor: (anchorId) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -212,9 +230,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
})),
}));
},
updateAnchor: (anchorId, patch) =>
updateAnchor: (anchorId, patch) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
@@ -223,9 +243,11 @@ export const createEditorSlice: StateCreator<
),
},
isDirty: true,
})),
}));
},
connect: (source, target) =>
connect: (source, target) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => {
if (
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] },
isDirty: true,
};
}),
});
},
disconnect: (edgeId) =>
disconnect: (edgeId) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
})),
}));
},
setEdges: (edges) =>
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
setEdges: (edges) => {
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,
}));
},
});

View 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: [] }),
});

View File

@@ -4,22 +4,26 @@ import { create } from "zustand";
import { createEditorSlice, type EditorSlice } from "./editor-slice";
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
import { createVersionSlice, type VersionSlice } from "./version-slice";
import { createHistorySlice, type HistorySlice } from "./history-slice";
/**
* V4 P2-6 修复:将单体 Zustand store原 303 行)拆分为 3 个独立 slice。
* V5-2 新增history-slice 撤销/重做栈50 步上限)。
*
* - editor-slice: 文档结构planId/title/doc及所有文档操作方法
* - 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 通过 `import type { EditorState }` 引用合并后的类型TypeScript
* 编译后该类型导入会被完全移除,运行时无循环依赖。
*/
export type EditorState = EditorSlice & SelectionSlice & VersionSlice;
export type EditorState = EditorSlice & SelectionSlice & VersionSlice & HistorySlice;
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
...createEditorSlice(...a),
...createSelectionSlice(...a),
...createVersionSlice(...a),
...createHistorySlice(...a),
}));

View File

@@ -6,10 +6,18 @@ export interface VersionSlice {
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
/** V5-1自动保存失败标记供 UI 显示兜底提示与重试按钮 */
saveError: boolean;
/** V5-1网络在线状态供 UI 显示断网提示 */
isOnline: boolean;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
markSaved: () => void;
setSaving: (saving: boolean) => void;
replaceDoc: (doc: LessonPlanDocument) => void;
/** V5-1标记保存失败/成功 */
setSaveError: (hasError: boolean) => void;
/** V5-1更新在线状态监听 window online/offline 事件) */
setOnline: (online: boolean) => void;
}
export const createVersionSlice: StateCreator<
@@ -21,6 +29,8 @@ export const createVersionSlice: StateCreator<
isDirty: false,
isSaving: false,
lastSavedAt: null,
saveError: false,
isOnline: true,
hydrate: (planId, title, doc) =>
set({
@@ -30,9 +40,14 @@ export const createVersionSlice: StateCreator<
isDirty: false,
lastSavedAt: Date.now(),
selectedNodeId: null,
saveError: false,
past: [],
future: [], // V5-2hydrate 时清空历史,避免跨课案污染
}),
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now(), saveError: false }),
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 }),
});

View File

@@ -0,0 +1,235 @@
/**
* V5-21 A3/A4/A5AI 差异化生成 + 课标实时核对 + 评估可解释。
*
* - 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),
})),
);
}
/**
* A3AI 差异化教学建议生成。
*/
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 [];
}
}
/**
* A4AI 课标实时核对。
*/
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 [];
}
}
/**
* A5AI 可解释评估。
*/
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 [];
}
}

View File

@@ -0,0 +1,135 @@
/**
* V5-17 A1/A2AI 反馈闭环 + 解释性展示。
*
* 调用 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: "" };
}
}

View 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);
}
// 孤立节点(无边)也要 setNodedagre 会自动排列
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;
}

View File

@@ -0,0 +1,137 @@
/**
* V5-19 T3目标-评价一致性校验(教学评一致性)。
*
* 校验规则:
* 1. 每个教学目标objective 节点应至少被一个评价exercise 节点)覆盖。
* 2. 每个 exercise 节点应至少关联一个知识点。
* 3. 课案应至少包含一个 objective 与一个 exercise仅 warning不阻断保存
*
* 输出为纯数据结构ConsistencyResultUI 层负责渲染。
* 该模块为纯函数,无副作用,便于单测。
*/
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-100100 表示完全一致) */
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");
}

View 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;
}

View 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(/&nbsp;/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];

View 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;
}

View File

@@ -171,6 +171,50 @@ export interface LessonPlanDataService {
message?: string;
errors?: Record<string, string[]>;
}>;
// ---- V5-5M6 附件库 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;
}
/**

View File

@@ -18,6 +18,11 @@ import {
} from "../actions";
import { getKnowledgePointOptionsAction } from "../actions-kp";
import { publishLessonPlanHomeworkAction } from "../actions-publish";
import {
getLessonPlanAttachmentsAction,
createLessonPlanAttachmentAction,
deleteLessonPlanAttachmentAction,
} from "../actions-attachments";
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 };
},
// V5-5M6 附件库 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 };
},
};
}

View File

@@ -71,6 +71,26 @@ export type LessonNodeType = BlockType | TextbookContentNodeType;
export interface RichTextBlockData {
html: 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
| 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 联合
export interface Block {
id: string;
@@ -216,6 +256,10 @@ export interface Block {
title: string;
data: BlockData;
order: number;
/** V5-15 T1教学阶段分组可选教师可显式归类节点*/
stage?: TeachingStage;
/** V5-18 W6差异化教学标记可选标注此节点的目标学生水平*/
differentiation?: DifferentiationLevel;
}
// 教学节点Block + 画布坐标)