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 规则引用
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// @contract-pending:studentLessonPlanView schema 未实现,全 MSW 兜底
|
||||
// @contract-pending scope-check:范围校验待 core-edu 服务接入后启用
|
||||
/**
|
||||
* 学生教案只读查看页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
@@ -13,12 +14,16 @@
|
||||
* - 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 } from "react";
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
useStudentLessonPlanView,
|
||||
@@ -35,6 +40,8 @@ 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");
|
||||
@@ -72,22 +79,49 @@ export function StudentLessonPlanViewClient(): React.ReactElement {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("title")}
|
||||
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 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,
|
||||
@@ -95,12 +129,327 @@ function LessonPlanViewBody({
|
||||
plan: LessonPlanViewData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("studentDomain.lessonPlans.view");
|
||||
return (
|
||||
<DetailSection title={t("sectionContent")}>
|
||||
<DetailField label={t("title")} value={plan.title} />
|
||||
<div className="prose prose-sm max-w-none whitespace-pre-wrap rounded-md border bg-card p-4 text-sm">
|
||||
{plan.content}
|
||||
|
||||
// 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>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user