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:
SpecialX
2026-06-24 12:02:42 +08:00
parent a48e7d0e27
commit 6bc113eaff
39 changed files with 2129 additions and 571 deletions

View File

@@ -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">