"use client"; // @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底 // @contract-pending scope-check:范围校验待 core-edu 服务接入后启用 /** * 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) * * 数据契约: * - studentLessonPlanView(planId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md * * 三态规范(§11.3 DoD): * - loading:DetailPageSkeleton * - error:errorNode 局部降级 * - notFound:data 为 null 时显示空态节点 * * 范围校验(@contract-pending scope-check): * - 当前 portal-shell 使用 MSW 兜底,无真实 ctx.dataScope * - TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围 * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 */ import { FileText } from "lucide-react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, type ReactNode } from "react"; import { useStudentLessonPlanView, type StudentLessonPlanView as LessonPlanViewData, } from "@/lib/api"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { DetailPageShell, DetailPageSkeleton, DetailSection, DetailField, } from "@/shared/components/page-templates"; import { notify } from "@/shared/lib/notify"; /** * 只读查看客户端主体。需由 server page 包裹在 中。 * * 全高度布局(h-[calc(100vh-4rem)])+ 内部 overflow-y-auto,提升阅读体验。 */ export function StudentLessonPlanViewClient(): React.ReactElement { const t = useTranslations("studentDomain.lessonPlans.view"); const tCommon = useTranslations("common"); const params = useParams<{ planId: string }>(); const planId = params?.planId ?? ""; // @contract-pending:MSW 兜底 const { data, loading, error } = useStudentLessonPlanView(planId); useEffect(() => { if (error) { notify.error(tCommon("error.loadFailed", { message: String(error) })); } }, [error, tCommon]); const errorNode = error ? (

{tCommon("error.loadFailed", { message: String(error) })}

) : undefined; const emptyNode = !loading && !error && !data ? ( ) : undefined; return (
} backHref="/shell/student/lesson-plans" loading={loading} loadingNode={} errorNode={errorNode} emptyNode={emptyNode} > {data ? : null}
); } /** * 构造副标题字符串:优先教材/章节,缺失时退化为学科/年级。 */ function buildSubtitle( plan: LessonPlanViewData, t: ReturnType, ): string { const parts: string[] = []; if (plan.textbookTitle) { parts.push(`${t("fieldTextbook")}: ${plan.textbookTitle}`); } if (plan.chapterTitle) { parts.push(`${t("fieldChapter")}: ${plan.chapterTitle}`); } if (parts.length === 0) { parts.push(`${t("fieldSubject")}: ${plan.subject}`); parts.push(`${t("fieldGrade")}: ${plan.grade}`); } return parts.join(" · "); } /** * 教案查看主体(基本信息 + 教案内容)。 * * 已发布状态检查:status !== "published" 时显示友好提示并阻止渲染内容。 * 范围校验占位:@contract-pending scope-check(见文件头 TODO) */ function LessonPlanViewBody({ plan, }: { plan: LessonPlanViewData; }): React.ReactElement { const t = useTranslations("studentDomain.lessonPlans.view"); // TODO: 待 core-edu 服务接入后,调用 assertPlanInScope(plan, ctx) 校验范围 // @contract-pending scope-check // 已发布状态检查:未发布时阻止渲染内容 if (plan.status && plan.status !== "published") { return (

{t("notPublished")}

); } return (
{plan.textbookTitle ? ( ) : null} {plan.chapterTitle ? ( ) : null}
); } /* ------------------------------------------------------------------ * * 富文档渲染(自研简易解析,不引入新依赖) * * 支持两种内容形式: * 1. 结构化 JSON:{ sections: [{ title, content, items }] } * 2. 简易 markdown 文本:标题(#/##/###、中文序号"一、二、…")、 * 有序列表(1.)、无序列表(-/*)、段落、行内加粗(**text**) * ------------------------------------------------------------------ */ /** 块级节点类型 */ type BlockNode = | { readonly type: "heading"; readonly level: 1 | 2 | 3; readonly text: string; } | { readonly type: "paragraph"; readonly text: string } | { readonly type: "list"; readonly ordered: boolean; readonly items: readonly string[]; }; /** 中文序号正则:一、二、三、... 十、十一、… */ const CN_HEADING_RE = /^[一二三四五六七八九十]+、\s*(.+)$/; /** 阿拉伯数字有序列表项:1. xxx */ const OL_RE = /^\d+\.\s+(.+)$/; /** 无序列表项:- xxx 或 * xxx */ const UL_RE = /^[-*]\s+(.+)$/; /** Markdown 标题:# xxx / ## xxx / ### xxx */ const MD_HEADING_RE = /^(#{1,3})\s+(.+)$/; /** 行内加粗:**text** */ const BOLD_RE = /\*\*([^*]+)\*\*/; /** 类型守卫:判断 value 是否为字符串 */ function isString(value: unknown): value is string { return typeof value === "string"; } /** 类型守卫:判断 value 是否为结构化 sections 文档 */ function isStructuredDoc(value: unknown): value is { sections: ReadonlyArray<{ title?: unknown; content?: unknown; items?: unknown; }>; } { if (typeof value !== "object" || value === null) return false; if (!("sections" in value)) return false; // 从 unknown 转换为具体类型(允许的 as 场景) const obj = value as { sections: unknown }; return Array.isArray(obj.sections); } /** 将数字钳制到 1-3 区间(用于标题层级) */ function clampLevel(n: number): 1 | 2 | 3 { if (n >= 3) return 3; if (n === 2) return 2; return 1; } /** * 解析教案内容字符串为块级节点数组。 * * 优先尝试 JSON 结构化解析;失败则按简易 markdown 文本解析。 */ function parseLessonContent(content: string): readonly BlockNode[] { if (content.trim().startsWith("{")) { try { const parsed: unknown = JSON.parse(content); if (isStructuredDoc(parsed)) { return parseStructuredSections(parsed.sections); } } catch { // JSON 解析失败,回退到文本解析 } } return parseMarkdownLike(content); } /** 解析结构化 sections 为块级节点 */ function parseStructuredSections( sections: ReadonlyArray<{ title?: unknown; content?: unknown; items?: unknown; }>, ): readonly BlockNode[] { const nodes: BlockNode[] = []; for (const section of sections) { if (isString(section.title) && section.title.length > 0) { nodes.push({ type: "heading", level: 2, text: section.title }); } if (isString(section.content) && section.content.length > 0) { nodes.push({ type: "paragraph", text: section.content }); } if (Array.isArray(section.items)) { const items = section.items.filter(isString); if (items.length > 0) { nodes.push({ type: "list", ordered: false, items }); } } } return nodes; } /** 解析简易 markdown 文本为块级节点 */ function parseMarkdownLike(content: string): readonly BlockNode[] { const lines = content.split(/\r?\n/); const nodes: BlockNode[] = []; let i = 0; while (i < lines.length) { const line = lines[i] ?? ""; const trimmed = line.trim(); // 空行:跳过(段落分隔由节点边界自然形成) if (trimmed === "") { i += 1; continue; } // Markdown 标题 const mdMatch = MD_HEADING_RE.exec(trimmed); if (mdMatch) { const hashes = mdMatch[1] ?? ""; const text = mdMatch[2] ?? ""; nodes.push({ type: "heading", level: clampLevel(hashes.length), text }); i += 1; continue; } // 中文序号标题(一、二、三、) const cnMatch = CN_HEADING_RE.exec(trimmed); if (cnMatch) { const text = cnMatch[1] ?? ""; nodes.push({ type: "heading", level: 2, text }); i += 1; continue; } // 有序列表 if (OL_RE.test(trimmed)) { const items: string[] = []; while (i < lines.length) { const cur = (lines[i] ?? "").trim(); const m = OL_RE.exec(cur); if (!m) break; items.push(m[1] ?? ""); i += 1; } nodes.push({ type: "list", ordered: true, items }); continue; } // 无序列表 if (UL_RE.test(trimmed)) { const items: string[] = []; while (i < lines.length) { const cur = (lines[i] ?? "").trim(); const m = UL_RE.exec(cur); if (!m) break; items.push(m[1] ?? ""); i += 1; } nodes.push({ type: "list", ordered: false, items }); continue; } // 段落 nodes.push({ type: "paragraph", text: trimmed }); i += 1; } return nodes; } /** 渲染行内文本(处理 **bold** 加粗) */ function renderInline(text: string): ReactNode[] { const parts: ReactNode[] = []; let remaining = text; let key = 0; while (remaining.length > 0) { const m = BOLD_RE.exec(remaining); if (!m) { parts.push(remaining); break; } if (m.index > 0) { parts.push(remaining.slice(0, m.index)); } parts.push( {m[1]} , ); key += 1; remaining = remaining.slice(m.index + m[0].length); } return parts; } /** * 富文档渲染组件。 * * 将教案内容字符串解析为块级节点并渲染:标题(h2/h3/h4)、段落(p)、 * 有序/无序列表(ol/ul + li)。纯文本场景保留 whitespace-pre-wrap。 */ function RichLessonContent({ content, }: { content: string; }): React.ReactElement { const nodes = useMemo(() => parseLessonContent(content), [content]); // 纯文本退化:仅单个段落且无结构时,保留 whitespace-pre-wrap if (nodes.length === 1 && nodes[0]?.type === "paragraph") { return (
{renderInline(nodes[0].text)}
); } return (
{nodes.map((node, idx) => { const key = `block-${idx}`; if (node.type === "heading") { if (node.level === 1) { return (

{renderInline(node.text)}

); } if (node.level === 2) { return (

{renderInline(node.text)}

); } return (

{renderInline(node.text)}

); } if (node.type === "paragraph") { return (

{renderInline(node.text)}

); } // list if (node.ordered) { return (
    {node.items.map((item, i) => (
  1. {renderInline(item)}
  2. ))}
); } return (
    {node.items.map((item, i) => (
  • {renderInline(item)}
  • ))}
); })}
); }