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

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