Files
Edu/apps/portal-shell/src/features/student/lesson-plans/lesson-plan-view-client.tsx
SpecialX 039db5efdd fix(portal-shell): 管理域 UI 规范合规与 TypeScript 修复
- 替换 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 规则引用
2026-08-01 05:50:25 +08:00

456 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// @contract-pendingstudentLessonPlanView 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
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 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-pendingMSW 兜底
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>
);
}