- 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
182 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|