Files
NextEdu/src/modules/lesson-preparation/components/blocks/exercise-block.tsx
SpecialX 25dca843be 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
2026-07-04 10:22:10 +08:00

182 lines
6.1 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { QuestionBankPicker } from "../question-bank-picker";
import { InlineQuestionEditor } from "../inline-question-editor";
import { PublishHomeworkDialog } from "../publish-homework-dialog";
import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import type {
ExerciseBlockData,
ExerciseItem,
} from "../../types";
import { isExercisePurpose } from "../../lib/type-guards";
interface Props {
blockId: string;
data: ExerciseBlockData;
classes: { id: string; name: string }[];
textbookId?: string;
chapterId?: string;
}
export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: Props) {
const t = useTranslations("lessonPreparation");
const router = useRouter();
const { updateNode, planId } = useLessonPlanEditor();
const [showBank, setShowBank] = useState(false);
const [showInline, setShowInline] = useState(false);
const [showPublish, setShowPublish] = useState(false);
function update(patch: Partial<ExerciseBlockData>) {
updateNode(blockId, { data: { ...data, ...patch } });
}
function addItems(items: ExerciseItem[]) {
const next = [...data.items, ...items];
update({
items: next.map((it, i) => ({ ...it, order: i })),
});
}
function removeItem(idx: number) {
update({
items: data.items
.filter((_, i) => i !== idx)
.map((it, i) => ({ ...it, order: i })),
});
}
return (
<div className="space-y-2">
<div className="flex gap-2 items-center">
<label htmlFor={`exercise-purpose-${blockId}`} className="text-sm font-medium">
{t("exercise.purposeLabel")}
</label>
<select
id={`exercise-purpose-${blockId}`}
value={data.purpose}
onChange={(e) => {
const value = e.target.value;
if (isExercisePurpose(value)) {
update({ purpose: value });
}
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
<option value="after_class_homework">{t("exercise.purpose.after_class_homework")}</option>
</select>
</div>
{data.items.length === 0 ? (
<p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
{t("questionBank.empty")}
</p>
) : (
<ul className="space-y-1 list-none p-0" role="list">
{data.items.map((item, idx) => (
<li
key={item.questionId}
className="flex items-center gap-2 border rounded p-2"
>
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
{item.source === "bank" ? t("questionBank.source.bank") : t("questionBank.source.inline")}
</span>
<span className="text-sm flex-1 truncate">
{item.source === "bank"
? t("questionBank.questionId", { id: item.questionId.slice(0, 8) })
: t("questionBank.inlineQuestion")}
</span>
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => setShowBank(true)}
>
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
{t("questionBank.fromBank")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowInline(true)}
>
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
{t("questionBank.inlineNew")}
</Button>
{data.publishedAssignmentId ? (
<div className="flex items-center gap-2 text-sm">
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
{t("status.publishedAsHomework")}
</span>
{/* V4 P1-5 修复:原生 <a> 替换为 next/link 的 <Link> */}
<Link
href="/teacher/homework"
className="text-primary underline"
>
{t("action.viewHomework")}
</Link>
</div>
) : (
data.purpose === "after_class_homework" &&
data.items.length > 0 && (
<Button
size="sm"
onClick={() => setShowPublish(true)}
>
{t("action.publish")}
</Button>
)
)}
</div>
{showBank && (
<LessonPlanErrorBoundary>
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
</LessonPlanErrorBoundary>
)}
{showInline && (
<LessonPlanErrorBoundary>
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
</LessonPlanErrorBoundary>
)}
{showPublish && (
<LessonPlanErrorBoundary>
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
items={data.items}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
</LessonPlanErrorBoundary>
)}
</div>
);
}