Files
NextEdu/src/modules/lesson-preparation/components/lesson-plan-card.tsx
SpecialX 20023e13fd feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff
- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
2026-07-03 10:25:21 +08:00

277 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { formatDateTime } from "@/shared/lib/utils";
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import type { LessonPlanListItem } from "../types";
export function LessonPlanCard({
plan,
viewMode = "teacher",
}: {
plan: LessonPlanListItem;
viewMode?: "teacher" | "student" | "parent" | "admin" | "gradeHead";
}) {
const t = useTranslations("lessonPreparation");
const router = useRouter();
const roleConfig = useRoleConfig();
const tracker = useLessonPlanTrackerSafe();
// V3 修复:完全通过 service 调用,不直接 import actions
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
// 根据视图模式决定跳转链接
const planHref =
viewMode === "teacher"
? `/teacher/lesson-plans/${plan.id}/edit`
: viewMode === "student"
? `/student/lesson-plans/${plan.id}/view`
: viewMode === "parent"
? `/parent/lesson-plans/${plan.id}/view`
: viewMode === "admin"
? `/admin/lesson-plans/${plan.id}/view`
: `/grade-head/lesson-plans/${plan.id}/view`;
// 根据视图模式生成指定版本的跳转链接
const getVersionHref = (versionId: string): string => {
const base =
viewMode === "teacher"
? `/teacher/lesson-plans/${versionId}/edit`
: viewMode === "student"
? `/student/lesson-plans/${versionId}/view`
: viewMode === "parent"
? `/parent/lesson-plans/${versionId}/view`
: viewMode === "admin"
? `/admin/lesson-plans/${versionId}/view`
: `/grade-head/lesson-plans/${versionId}/view`;
return base;
};
// 是否有多版本
const hasMultipleVersions = plan.versionCount > 1;
// 只读视图(非 teacher不显示编辑操作
const isReadOnly = viewMode !== "teacher";
// P2 修复:使用 useCallback 包裹异步处理函数,避免每次渲染重新创建
const handleArchive = useCallback(async () => {
if (!service) return;
try {
const res = await service.deleteLessonPlan(plan.id);
if (res.success) {
tracker.track("lesson_plan.archive", { planId: plan.id });
toast.success(t("status.archived"));
router.refresh();
} else {
toast.error(res.message ?? t("error.delete"));
}
} catch (e) {
console.error("[LessonPlanCard] archive failed", e);
toast.error(t("error.delete"));
}
}, [service, plan.id, tracker, t, router]);
const handleDuplicate = useCallback(async () => {
if (!service) return;
try {
const res = await service.duplicateLessonPlan(plan.id);
if (res.success) {
tracker.track("lesson_plan.duplicate", { planId: plan.id });
router.refresh();
} else {
toast.error(res.message ?? t("error.duplicate"));
}
} catch (e) {
console.error("[LessonPlanCard] duplicate failed", e);
toast.error(t("error.duplicate"));
}
}, [service, plan.id, tracker, t, router]);
const handlePublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.publishLessonPlan(plan.id);
if (res.success) {
tracker.track("lesson_plan.publish", { planId: plan.id });
toast.success(res.message ?? t("action.publishPlanSuccess"));
router.refresh();
} else {
toast.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[LessonPlanCard] publish failed", e);
toast.error(t("error.save"));
}
}, [service, plan.id, tracker, t, router]);
const handleUnpublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.unpublishLessonPlan(plan.id);
if (res.success) {
tracker.track("lesson_plan.unpublish", { planId: plan.id });
toast.success(res.message ?? t("action.unpublishPlanSuccess"));
router.refresh();
} else {
toast.error(res.message ?? t("error.save"));
}
} catch (e) {
console.error("[LessonPlanCard] unpublish failed", e);
toast.error(t("error.save"));
}
}, [service, plan.id, tracker, t, router]);
return (
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
<div className="flex items-start justify-between gap-2">
<Link
href={planHref}
className="block flex-1 min-w-0"
>
<h3 className="font-title-md text-title-md hover:text-primary truncate">
{plan.title}
</h3>
</Link>
{hasMultipleVersions && (
<span className="shrink-0 inline-flex items-center rounded-full bg-primary-container px-2 py-0.5 text-xs font-medium text-on-primary-container">
{t("list.versionCount", { count: plan.versionCount })}
</span>
)}
</div>
<div className="text-sm text-on-surface-variant mt-1">
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{plan.templateName ?? t("list.noTemplate")} ·{" "}
{t(`status.${plan.status}`)}
</div>
<div className="text-xs text-on-surface-variant mt-2">
{t("list.lastSaved")}
{plan.lastSavedAt
? formatDateTime(plan.lastSavedAt)
: t("list.neverSaved")}
</div>
{/* 版本选择器:仅多版本时显示 */}
{hasMultipleVersions && (
<div className="mt-2 flex items-center gap-2">
<label htmlFor={`version-select-${plan.id}`} className="text-xs text-on-surface-variant">
{t("list.versionSelectorLabel")}
</label>
<select
id={`version-select-${plan.id}`}
className="flex-1 text-xs border border-outline-variant rounded bg-surface px-2 py-1 text-on-surface focus:outline-none focus:ring-1 focus:ring-primary"
value={plan.id}
onChange={(e) => {
const selectedId = e.target.value;
if (selectedId && selectedId !== plan.id) {
router.push(getVersionHref(selectedId));
}
}}
>
{plan.versions.map((v, idx) => (
<option key={v.id} value={v.id}>
{`v${plan.versions.length - idx} · ${t(`status.${v.status}`)} · ${formatDateTime(v.updatedAt)}`}
{idx === 0 ? ` (${t("list.versionCurrent")})` : ""}
</option>
))}
</select>
</div>
)}
<div className="flex gap-2 mt-3">
{roleConfig.canDuplicate && !isReadOnly && (
<Button variant="outline" size="sm" onClick={handleDuplicate}>
{t("action.duplicate")}
</Button>
)}
{/* 发布/撤回发布按钮(仅教师视图)*/}
{!isReadOnly && plan.status === "draft" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="default" size="sm">
{t("action.publishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.publishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.publishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handlePublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{!isReadOnly && plan.status === "published" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
{t("action.unpublishPlan")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("action.unpublishPlan")}</AlertDialogTitle>
<AlertDialogDescription>
{t("action.unpublishPlanConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleUnpublish}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{roleConfig.canArchive && !isReadOnly && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
{t("action.archive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirm.archiveTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("confirm.archive")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>
{t("action.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
);
}