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:
SpecialX
2026-08-01 05:50:25 +08:00
parent f991bf0446
commit 039db5efdd
130 changed files with 15138 additions and 3235 deletions

View File

@@ -1,6 +1,7 @@
"use client";
// @contract-pendingstudentLessonPlanView schema 未实现,全 MSW 兜底
// @contract-pending scope-check范围校验待 core-edu 服务接入后启用
/**
* 学生教案只读查看页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
@@ -13,12 +14,16 @@
* - 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 } 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>
);
}