feat(lesson-preparation): add readonly view, anchor node selector, and type guards
- Add lesson-plan-readonly-view for viewing published plans - Add anchor-node-selector and textbook-segments for canvas anchor positioning - Add i18n-errors and type-guards lib utilities - Add lesson-plan-provider-setup for provider initialization - Update actions, data-access (knowledge, versions, main), publish-service - Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection) - Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
@@ -17,25 +17,62 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog";
|
||||
import { formatDateTime } from "@/shared/lib/utils";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import { useLessonPlanContextSafe, useRoleConfig, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
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();
|
||||
|
||||
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||
// 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";
|
||||
|
||||
async function handleArchive() {
|
||||
if (!service) return;
|
||||
try {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
const res = await service.deleteLessonPlan(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
@@ -50,10 +87,9 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (!service) return;
|
||||
try {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
const res = await service.duplicateLessonPlan(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
@@ -66,16 +102,57 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
<Link
|
||||
href={`/teacher/lesson-plans/${plan.id}/edit`}
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-title-md text-title-md hover:text-primary">
|
||||
{plan.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<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>
|
||||
@@ -89,13 +166,86 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
? 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 && (
|
||||
{roleConfig.canDuplicate && !isReadOnly && (
|
||||
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||
{t("action.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{roleConfig.canArchive && (
|
||||
{/* 发布/撤回发布按钮(仅教师视图)*/}
|
||||
{!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">
|
||||
|
||||
Reference in New Issue
Block a user