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:
@@ -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() };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user