- 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
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|