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

@@ -6,6 +6,7 @@
*
* 数据契约:
* - studentLessonPlans ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - subjectOptions ❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
@@ -15,9 +16,19 @@
import { FileText } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { useStudentLessonPlans, type StudentLessonPlan } from "@/lib/api";
import {
useStudentLessonPlans,
useSubjectOptions,
type StudentLessonPlan,
} from "@/lib/api";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
ListPageShell,
@@ -34,6 +45,10 @@ export function StudentLessonPlansListClient(): React.ReactElement {
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudentLessonPlans();
const { data: subjectOptions } = useSubjectOptions();
// 学科筛选状态subject id
const [subjectFilter, setSubjectFilter] = useState<string>("");
useEffect(() => {
if (error) {
@@ -41,7 +56,16 @@ export function StudentLessonPlansListClient(): React.ReactElement {
}
}, [error, tCommon]);
const items = data ?? [];
// 仅展示已发布教案(不在前端展示草稿),并按学科筛选
const visibleItems = useMemo(() => {
const published = (data ?? []).filter((p) => p.status === "published");
if (!subjectFilter) return published;
const subjectName = (subjectOptions ?? []).find(
(s) => s.id === subjectFilter,
)?.name;
if (!subjectName) return published;
return published.filter((p) => p.subject === subjectName);
}, [data, subjectFilter, subjectOptions]);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
@@ -68,71 +92,88 @@ export function StudentLessonPlansListClient(): React.ReactElement {
title={t("title")}
description={t("description")}
icon={<FileText className="size-6" />}
filters={
<select
value={subjectFilter}
onChange={(e) => setSubjectFilter(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm md:w-60"
aria-label={t("subjectFilter")}
>
<option value="">{t("allSubjects")}</option>
{(subjectOptions ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={items.length === 0 && !loading}
empty={visibleItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("total", { count: items.length })}</span>
<span>{t("total", { count: visibleItems.length })}</span>
</div>
}
>
<StudentLessonPlansTable items={items} />
<StudentLessonPlansGrid items={visibleItems} />
</ListPageShell>
);
}
/**
* 学生教案列表表格(纯展示组件,对齐 §8.2 排版规范)。
* 学生教案卡片网格md:grid-cols-2 lg:grid-cols-3复刻 CICD lesson-plan-list)。
*/
function StudentLessonPlansTable({
function StudentLessonPlansGrid({
items,
}: {
items: StudentLessonPlan[];
}): React.ReactElement {
const t = useTranslations("studentDomain.lessonPlans.list");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colTitle")}</th>
<th className="p-3 text-left font-medium">{t("colSubject")}</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colUpdatedAt")}</th>
<th className="p-3 text-right font-medium">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((plan) => (
<tr key={plan.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/student/lesson-plans/${plan.id}/view`}
className="font-medium hover:underline"
>
{plan.title}
</Link>
</td>
<td className="p-3 text-muted-foreground">{plan.subject}</td>
<td className="p-3 text-muted-foreground">{plan.grade}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{plan.updatedAt}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/student/lesson-plans/${plan.id}/view`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<Card
key={plan.id}
className="group flex flex-col transition-all hover:-translate-y-1 hover:shadow-md"
>
<CardHeader className="pb-3">
<CardTitle className="line-clamp-2 text-base font-semibold">
{plan.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-xs text-muted-foreground">
{t("colSubject")}
</dt>
<dd className="font-medium">{plan.subject}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">
{t("colGrade")}
</dt>
<dd className="font-medium">{plan.grade}</dd>
</div>
</dl>
<p className="font-mono text-xs text-muted-foreground">
{plan.updatedAt}
</p>
<div className="mt-auto pt-2">
<Link
href={`/shell/student/lesson-plans/${plan.id}/view`}
className="inline-flex items-center text-xs font-medium text-primary hover:underline"
>
{t("viewDetail")}
</Link>
</div>
</CardContent>
</Card>
))}
</div>
);
}

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>
);
}