- 替换 41 处原生 select 为 Select 组件封装 - 替换 5 处 window.confirm 为 shadcn AlertDialog - 修复 lesson-plans delete-confirm-dialog 为 AlertDialog - 修复 5 处 Tailwind 任意值 text-[10px] - 修复 graphql-data.ts mutation case 缺少 id 定义 - 修复 use-position-persistence.ts eslint 规则引用
456 lines
13 KiB
TypeScript
456 lines
13 KiB
TypeScript
"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 包裹在 <Suspense> 中。
|
||
*
|
||
* 全高度布局(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 ? (
|
||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||
<p className="text-sm text-destructive">
|
||
{tCommon("error.loadFailed", { message: String(error) })}
|
||
</p>
|
||
</div>
|
||
) : undefined;
|
||
|
||
const emptyNode =
|
||
!loading && !error && !data ? (
|
||
<EmptyState
|
||
icon={FileText}
|
||
title={t("notFound")}
|
||
action={{
|
||
label: t("backToList"),
|
||
href: "/shell/student/lesson-plans",
|
||
}}
|
||
/>
|
||
) : undefined;
|
||
|
||
return (
|
||
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
|
||
<DetailPageShell
|
||
title={data?.title ?? t("title")}
|
||
description={data ? buildSubtitle(data, t) : undefined}
|
||
icon={<FileText className="size-6" />}
|
||
backHref="/shell/student/lesson-plans"
|
||
loading={loading}
|
||
loadingNode={<DetailPageSkeleton />}
|
||
errorNode={errorNode}
|
||
emptyNode={emptyNode}
|
||
>
|
||
{data ? <LessonPlanViewBody plan={data} /> : null}
|
||
</DetailPageShell>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 构造副标题字符串:优先教材/章节,缺失时退化为学科/年级。
|
||
*/
|
||
function buildSubtitle(
|
||
plan: LessonPlanViewData,
|
||
t: ReturnType<typeof useTranslations>,
|
||
): 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 (
|
||
<div className="rounded-xl border border-border bg-muted/50 p-6 text-center">
|
||
<p className="text-sm text-muted-foreground">{t("notPublished")}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<DetailSection title={t("sectionBasic")}>
|
||
<DetailField label={t("fieldSubject")} value={plan.subject} />
|
||
<DetailField label={t("fieldGrade")} value={plan.grade} />
|
||
{plan.textbookTitle ? (
|
||
<DetailField label={t("fieldTextbook")} value={plan.textbookTitle} />
|
||
) : null}
|
||
{plan.chapterTitle ? (
|
||
<DetailField label={t("fieldChapter")} value={plan.chapterTitle} />
|
||
) : null}
|
||
</DetailSection>
|
||
|
||
<DetailSection title={t("sectionContent")}>
|
||
<RichLessonContent content={plan.content} />
|
||
</DetailSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 富文档渲染(自研简易解析,不引入新依赖)
|
||
*
|
||
* 支持两种内容形式:
|
||
* 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(
|
||
<strong key={`b-${key}`} className="font-semibold">
|
||
{m[1]}
|
||
</strong>,
|
||
);
|
||
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 (
|
||
<div className="whitespace-pre-wrap rounded-md border bg-card p-4 text-sm leading-7 text-foreground">
|
||
{renderInline(nodes[0].text)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-md border bg-card p-4">
|
||
{nodes.map((node, idx) => {
|
||
const key = `block-${idx}`;
|
||
if (node.type === "heading") {
|
||
if (node.level === 1) {
|
||
return (
|
||
<h2
|
||
key={key}
|
||
className="mt-4 text-xl font-bold text-foreground first:mt-0"
|
||
>
|
||
{renderInline(node.text)}
|
||
</h2>
|
||
);
|
||
}
|
||
if (node.level === 2) {
|
||
return (
|
||
<h3
|
||
key={key}
|
||
className="mt-3 text-lg font-semibold text-foreground first:mt-0"
|
||
>
|
||
{renderInline(node.text)}
|
||
</h3>
|
||
);
|
||
}
|
||
return (
|
||
<h4
|
||
key={key}
|
||
className="mt-2 text-base font-semibold text-foreground first:mt-0"
|
||
>
|
||
{renderInline(node.text)}
|
||
</h4>
|
||
);
|
||
}
|
||
if (node.type === "paragraph") {
|
||
return (
|
||
<p
|
||
key={key}
|
||
className="whitespace-pre-wrap leading-7 text-foreground"
|
||
>
|
||
{renderInline(node.text)}
|
||
</p>
|
||
);
|
||
}
|
||
// list
|
||
if (node.ordered) {
|
||
return (
|
||
<ol key={key} className="ml-5 list-decimal space-y-1">
|
||
{node.items.map((item, i) => (
|
||
<li key={`li-${i}`} className="leading-7 text-foreground">
|
||
{renderInline(item)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
);
|
||
}
|
||
return (
|
||
<ul key={key} className="ml-5 list-disc space-y-1">
|
||
{node.items.map((item, i) => (
|
||
<li key={`li-${i}`} className="leading-7 text-foreground">
|
||
{renderInline(item)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|