feat(portal-shell): questions + textbooks 模块 3 页迁移(教师域 §9.1 B2)

§9.1 line 630-631 教师域:
- /shell/teacher/questions (列表,1 页)
- /shell/teacher/textbooks + /shell/teacher/textbooks/[id] (列表+详情,2 页)
契约:🟡 混合
- question(id)  真实单查(schema 第 775-778 行确认)
- textbook(id)  真实单查
- 列表查询  schema 无 → MSW 兜底 + @contract-pending
- textbookChapters(textbookId)  schema 无 → MSW 兜底

新增文件:
- src/lib/api/questions.ts (5 hooks)
- src/lib/api/textbooks.ts (5 hooks)
- src/lib/api/operations/{questions,textbooks}.graphql.ts (10 documents)
- src/features/teacher/questions/ (clients + transformations + tests)
- src/features/teacher/textbooks/ (clients + transformations + tests)
- src/app/shell/teacher/{questions,textbooks}/ (3 page.tsx + 2 loading + 2 error)

修改文件:
- src/lib/api/teacher.ts + operations/teacher.graphql.ts
  → 重命名 legacy widget API 以解决命名冲突:
    Question → QuestionBankItem
    Textbook → LegacyTextbook
    Chapter → LegacyChapter
    TextbookFilter → LegacyTextbookFilter
    useTextbooks → useLegacyTextbooks
    GET_QUESTIONS_DOC → GET_QUESTION_BANK_DOC
    GET_TEXTBOOKS_DOC → GET_LEGACY_TEXTBOOKS_DOC
- src/widgets/teacher/{question-bank,textbook-manager}/index.tsx
  → 更新引用为重命名后的 legacy API
- src/mocks/graphql-data.ts
  → 添加 questions/textbooks mock + GetQuestionBank/GetLegacyTextbooks handler
- src/messages/{zh-CN,en}.json (questions + textbooks i18n)
- src/lib/api/{index,operations/index}.ts (导出 questions + textbooks)
- src/shared/lib/route-permissions.ts (questions + textbooks 路由权限)
- scripts/check-page-count.ts (baseline 37 → 40)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 469 tests passed (新增 64 tests)
- lint:tokens 0 errors
- check:pages 40 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 20:29:59 +08:00
parent 8ab4fae9d5
commit 33ebb9a652
29 changed files with 3321 additions and 62 deletions

View File

@@ -0,0 +1,249 @@
/**
* Questions 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Question } from "@/lib/api";
import {
QUESTION_STATUS_LABEL,
QUESTION_TYPE_LABEL,
difficultyToColorClass,
formatDifficulty,
formatQuestionDate,
formatQuestionStatus,
formatQuestionType,
isQuestionEditable,
isQuestionPublished,
questionStatusToBadgeClass,
questionTypeToBadgeClass,
toQuestionListItem,
truncateContent,
} from "../transformations";
describe("formatQuestionType", () => {
it("maps known types to Chinese labels", () => {
expect(formatQuestionType("single_choice")).toBe("单选题");
expect(formatQuestionType("multiple_choice")).toBe("多选题");
expect(formatQuestionType("fill_blank")).toBe("填空题");
expect(formatQuestionType("short_answer")).toBe("简答题");
expect(formatQuestionType("essay")).toBe("论述题");
expect(formatQuestionType("true_false")).toBe("判断题");
});
it("returns original value for unknown type", () => {
expect(formatQuestionType("unknown_type")).toBe("unknown_type");
expect(formatQuestionType("")).toBe("");
});
it("QUESTION_TYPE_LABEL covers 6 standard types", () => {
expect(Object.keys(QUESTION_TYPE_LABEL)).toHaveLength(6);
});
});
describe("formatQuestionStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatQuestionStatus("DRAFT")).toBe("草稿");
expect(formatQuestionStatus("PUBLISHED")).toBe("已发布");
expect(formatQuestionStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatQuestionStatus("UNKNOWN")).toBe("UNKNOWN");
});
it("QUESTION_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(QUESTION_STATUS_LABEL)).toHaveLength(3);
});
});
describe("formatDifficulty", () => {
it("maps string enums to Chinese labels", () => {
expect(formatDifficulty("easy")).toBe("简单");
expect(formatDifficulty("medium")).toBe("中等");
expect(formatDifficulty("hard")).toBe("困难");
});
it("maps numeric values by thresholds", () => {
expect(formatDifficulty(0)).toBe("简单");
expect(formatDifficulty(0.4)).toBe("简单");
expect(formatDifficulty(0.41)).toBe("中等");
expect(formatDifficulty(0.7)).toBe("中等");
expect(formatDifficulty(0.71)).toBe("困难");
expect(formatDifficulty(1)).toBe("困难");
});
it("returns placeholder for non-finite numeric input", () => {
expect(formatDifficulty(Number.NaN)).toBe("--");
expect(formatDifficulty(Number.POSITIVE_INFINITY)).toBe("--");
});
it("returns original string for unknown enum", () => {
expect(formatDifficulty("extreme")).toBe("extreme");
});
});
describe("formatQuestionDate", () => {
it("formats valid ISO date string", () => {
const result = formatQuestionDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatQuestionDate(null)).toBe("--");
expect(formatQuestionDate(undefined)).toBe("--");
expect(formatQuestionDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatQuestionDate("not-a-date")).toBe("--");
});
});
describe("truncateContent", () => {
it("returns text unchanged when within limit", () => {
expect(truncateContent("短题干", 10)).toBe("短题干");
});
it("truncates and appends ellipsis when over limit", () => {
const long = "a".repeat(80);
const result = truncateContent(long, 60);
expect(result.endsWith("...")).toBe(true);
expect(result.length).toBe(63);
});
it("collapses whitespace", () => {
expect(truncateContent("题干\n带\n换行", 60)).toBe("题干 带 换行");
});
it("uses default maxLen of 60", () => {
const long = "b".repeat(70);
const result = truncateContent(long);
expect(result.endsWith("...")).toBe(true);
});
});
describe("toQuestionListItem", () => {
it("extracts list fields from full question", () => {
const q: Question = {
id: "q-001",
knowledgePointId: "kp-001",
type: "single_choice",
content: "下列哪个是质数?",
answer: "B",
explanation: "7 只能被 1 和自身整除",
difficulty: 0.3,
status: "PUBLISHED",
source: "人教版必修一",
createdBy: "usr-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
const item = toQuestionListItem(q);
expect(item.id).toBe("q-001");
expect(item.type).toBe("single_choice");
expect(item.content).toBe("下列哪个是质数?");
expect(item.difficulty).toBe("0.3");
expect(item.status).toBe("PUBLISHED");
expect(item.subjectId).toBeNull();
expect(item.textbookId).toBeNull();
expect(item).not.toHaveProperty("answer");
expect(item).not.toHaveProperty("explanation");
expect(item).not.toHaveProperty("createdBy");
expect(item).not.toHaveProperty("updatedAt");
});
});
describe("questionTypeToBadgeClass", () => {
it("returns blue class for choice types", () => {
expect(questionTypeToBadgeClass("single_choice")).toContain("blue");
expect(questionTypeToBadgeClass("multiple_choice")).toContain("blue");
});
it("returns emerald class for fill_blank", () => {
expect(questionTypeToBadgeClass("fill_blank")).toContain("emerald");
});
it("returns amber class for short_answer and essay", () => {
expect(questionTypeToBadgeClass("short_answer")).toContain("amber");
expect(questionTypeToBadgeClass("essay")).toContain("amber");
});
it("returns purple class for true_false", () => {
expect(questionTypeToBadgeClass("true_false")).toContain("purple");
});
it("returns muted for unknown type", () => {
expect(questionTypeToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("difficultyToColorClass", () => {
it("returns emerald for easy (string)", () => {
expect(difficultyToColorClass("easy")).toBe("text-emerald-600");
});
it("returns amber for medium (string)", () => {
expect(difficultyToColorClass("medium")).toBe("text-amber-600");
});
it("returns destructive for hard (string)", () => {
expect(difficultyToColorClass("hard")).toBe("text-destructive");
});
it("returns emerald for low numeric difficulty", () => {
expect(difficultyToColorClass(0.2)).toBe("text-emerald-600");
});
it("returns destructive for high numeric difficulty", () => {
expect(difficultyToColorClass(0.9)).toBe("text-destructive");
});
it("returns muted for unknown string", () => {
expect(difficultyToColorClass("extreme")).toBe("text-muted-foreground");
});
});
describe("questionStatusToBadgeClass", () => {
it("returns primary for PUBLISHED", () => {
expect(questionStatusToBadgeClass("PUBLISHED")).toContain("primary");
});
it("returns muted for DRAFT and ARCHIVED", () => {
expect(questionStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(questionStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted for unknown status", () => {
expect(questionStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("isQuestionEditable / isQuestionPublished", () => {
it("DRAFT is editable but not published", () => {
expect(isQuestionEditable("DRAFT")).toBe(true);
expect(isQuestionPublished("DRAFT")).toBe(false);
});
it("PUBLISHED is published but not editable", () => {
expect(isQuestionEditable("PUBLISHED")).toBe(false);
expect(isQuestionPublished("PUBLISHED")).toBe(true);
});
it("unknown status is neither editable nor published", () => {
expect(isQuestionEditable("UNKNOWN")).toBe(false);
expect(isQuestionPublished("UNKNOWN")).toBe(false);
});
});

View File

@@ -0,0 +1,279 @@
"use client";
/**
* 题库管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 questions(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 单查 question(id):✅ 真实字段(本页未使用,详情页预留)
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#questions-list
*
* URL 状态:?type=&difficulty=&subjectId=&textbookId=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { HelpCircle, Plus } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useQuestions, type QuestionListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
difficultyToColorClass,
formatDifficulty,
formatQuestionDate,
formatQuestionType,
questionTypeToBadgeClass,
truncateContent,
} from "@/features/teacher/questions/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function QuestionsListClient(): React.ReactElement {
const t = useTranslations("questions");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const typeFilter = searchParams.get("type") ?? "";
const difficultyFilter = searchParams.get("difficulty") ?? "";
const subjectId = searchParams.get("subjectId") ?? "";
const textbookId = searchParams.get("textbookId") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useQuestions({
type: typeFilter || undefined,
difficulty: difficultyFilter || undefined,
subjectId: subjectId || undefined,
textbookId: textbookId || undefined,
q: q || undefined,
});
// 客户端二次筛选兜底q 在 MSW 已支持,此处保留以备切换真实 fetcher
const filteredItems = useMemo<QuestionListItem[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/questions?${params.toString()}`);
});
};
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={HelpCircle}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/questions",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<HelpCircle className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/questions">
<Plus className="mr-1 size-4" />
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={typeFilter}
onChange={(e) => updateQuery("type", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.typeFilter")}
>
<option value="">{t("list.typeAll")}</option>
<option value="single_choice">{t("list.typeSingleChoice")}</option>
<option value="multiple_choice">
{t("list.typeMultipleChoice")}
</option>
<option value="fill_blank">{t("list.typeFillBlank")}</option>
<option value="short_answer">{t("list.typeShortAnswer")}</option>
<option value="essay">{t("list.typeEssay")}</option>
<option value="true_false">{t("list.typeTrueFalse")}</option>
</select>
<select
value={difficultyFilter}
onChange={(e) => updateQuery("difficulty", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.difficultyFilter")}
>
<option value="">{t("list.difficultyAll")}</option>
<option value="easy">{t("list.difficultyEasy")}</option>
<option value="medium">{t("list.difficultyMedium")}</option>
<option value="hard">{t("list.difficultyHard")}</option>
</select>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("list.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.subjectPlaceholder")}
/>
<input
type="text"
value={textbookId}
onChange={(e) => updateQuery("textbookId", e.target.value)}
placeholder={t("list.textbookPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.textbookPlaceholder")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<QuestionsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 题目列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function QuestionsTable({
items,
}: {
items: QuestionListItem[];
}): React.ReactElement {
const t = useTranslations("questions");
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("list.colContent")}
</th>
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
<th className="p-3 text-left font-medium">
{t("list.colDifficulty")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colSubject")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colTextbook")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colCreatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((q) => (
<tr key={q.id} className="hover:bg-muted/30">
<td className="max-w-xs p-3">
<span className="font-medium">
{truncateContent(q.content)}
</span>
</td>
<td className="p-3">
<QuestionTypeBadge type={q.type} />
</td>
<td className="p-3">
<span
className={`text-xs font-medium ${difficultyToColorClass(q.difficulty)}`}
>
{formatDifficulty(q.difficulty)}
</span>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{q.subjectId ?? "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{q.textbookId ?? "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatQuestionDate(q.createdAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/questions?id=${q.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 题型徽章(按题型色阶展示)。
*/
function QuestionTypeBadge({ type }: { type: string }): React.ReactElement {
const label = formatQuestionType(type);
const cls = questionTypeToBadgeClass(type);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,185 @@
/**
* Questions 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Question, QuestionListItem } from "@/lib/api";
/** 题型中文标签映射(对齐 schema Question.type 字符串语义) */
export const QUESTION_TYPE_LABEL: Record<string, string> = {
single_choice: "单选题",
multiple_choice: "多选题",
fill_blank: "填空题",
short_answer: "简答题",
essay: "论述题",
true_false: "判断题",
};
/** 题目状态中文标签映射 */
export const QUESTION_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
/**
* 将题型映射为中文标签。未知题型回退为原始值。
*/
export function formatQuestionType(type: string): string {
return QUESTION_TYPE_LABEL[type] ?? type;
}
/**
* 将题目状态映射为中文标签。未知状态回退为原始值。
*/
export function formatQuestionStatus(status: string): string {
return QUESTION_STATUS_LABEL[status] ?? status;
}
/**
* 格式化难度。
*
* schema Question.difficulty 是 Float但业务列表 mock 数据常用字符串枚举
* easy/medium/hard。本函数同时支持两种输入
* - 字符串枚举:直接映射("easy" → "简单"
* - 数值0~0.4 → 简单0.4~0.7 → 中等0.7~1.0 → 困难
* - 其他:回退原始字符串
*/
export function formatDifficulty(difficulty: string | number): string {
if (typeof difficulty === "number") {
if (!Number.isFinite(difficulty)) return "--";
if (difficulty <= 0.4) return "简单";
if (difficulty <= 0.7) return "中等";
return "困难";
}
switch (difficulty) {
case "easy":
return "简单";
case "medium":
return "中等";
case "hard":
return "困难";
default:
return difficulty;
}
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatQuestionDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 截断题干文本用于列表展示。
* - 超过 maxLen 字符时截断并加省略号
* - 移除换行符(列表单行展示)
* - maxLen 默认 60
*/
export function truncateContent(content: string, maxLen = 60): string {
const text = content.replace(/\s+/g, " ").trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen)}...`;
}
/**
* 从题目详情中提取列表项视图模型(裁剪字段)。
*
* 注Question 详情无 subjectId/textbookId 字段schema 未提供),
* 列表项的这两个字段由 MSW mock 扩展,详情→列表裁剪时置为 null。
*/
export function toQuestionListItem(question: Question): QuestionListItem {
return {
id: question.id,
type: question.type,
content: question.content,
difficulty: String(question.difficulty),
status: question.status,
source: question.source,
knowledgePointId: question.knowledgePointId,
subjectId: null,
textbookId: null,
createdAt: question.createdAt,
};
}
/**
* 根据题型返回 Tailwind 徽章语义类名。
*/
export function questionTypeToBadgeClass(type: string): string {
switch (type) {
case "single_choice":
case "multiple_choice":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "fill_blank":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "short_answer":
case "essay":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "true_false":
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据难度返回 Tailwind 文本语义类名。
* 支持字符串枚举与数值输入。
*/
export function difficultyToColorClass(difficulty: string | number): string {
const label = formatDifficulty(difficulty);
switch (label) {
case "简单":
return "text-emerald-600";
case "中等":
return "text-amber-600";
case "困难":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
/**
* 根据题目状态返回 Tailwind 徽章语义类名。
*/
export function questionStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 判断题目是否处于可编辑状态DRAFT
*/
export function isQuestionEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断题目是否已发布PUBLISHED
*/
export function isQuestionPublished(status: string): boolean {
return status === "PUBLISHED";
}

View File

@@ -0,0 +1,267 @@
/**
* Textbooks 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Textbook, TextbookChapter } from "@/lib/api";
import {
CHAPTER_STATUS_LABEL,
TEXTBOOK_STATUS_LABEL,
chapterStatusToBadgeClass,
formatChapterCount,
formatChapterOrder,
formatChapterStatus,
formatTextbookDate,
formatTextbookStatus,
isTextbookEditable,
isTextbookPublished,
sortChaptersByOrder,
textbookStatusToBadgeClass,
toTextbookListItem,
truncateTitle,
} from "../transformations";
describe("formatTextbookStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatTextbookStatus("DRAFT")).toBe("草稿");
expect(formatTextbookStatus("PUBLISHED")).toBe("已发布");
expect(formatTextbookStatus("DEPRECATED")).toBe("已弃用");
expect(formatTextbookStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatTextbookStatus("UNKNOWN")).toBe("UNKNOWN");
expect(formatTextbookStatus("")).toBe("");
});
it("TEXTBOOK_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(TEXTBOOK_STATUS_LABEL)).toHaveLength(4);
});
});
describe("formatChapterStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatChapterStatus("DRAFT")).toBe("草稿");
expect(formatChapterStatus("PUBLISHED")).toBe("已发布");
expect(formatChapterStatus("ARCHIVED")).toBe("已归档");
});
it("returns original value for unknown status", () => {
expect(formatChapterStatus("UNKNOWN")).toBe("UNKNOWN");
});
it("CHAPTER_STATUS_LABEL covers all standard statuses", () => {
expect(Object.keys(CHAPTER_STATUS_LABEL)).toHaveLength(3);
});
});
describe("formatTextbookDate", () => {
it("formats valid ISO date string", () => {
const result = formatTextbookDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatTextbookDate(null)).toBe("--");
expect(formatTextbookDate(undefined)).toBe("--");
expect(formatTextbookDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatTextbookDate("not-a-date")).toBe("--");
});
});
describe("truncateTitle", () => {
it("returns title unchanged when within limit", () => {
expect(truncateTitle("高中数学必修一", 20)).toBe("高中数学必修一");
});
it("truncates and appends ellipsis when over limit", () => {
const long = "a".repeat(50);
const result = truncateTitle(long, 40);
expect(result.endsWith("...")).toBe(true);
expect(result.length).toBe(43);
});
it("uses default maxLen of 40", () => {
const long = "b".repeat(45);
const result = truncateTitle(long);
expect(result.endsWith("...")).toBe(true);
});
});
describe("sortChaptersByOrder", () => {
it("sorts chapters by order ascending", () => {
const chapters: TextbookChapter[] = [
{
id: "ch-2",
textbookId: "tb-001",
title: "第二章",
order: 2,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
{
id: "ch-1",
textbookId: "tb-001",
title: "第一章",
order: 1,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
{
id: "ch-3",
textbookId: "tb-001",
title: "第三章",
order: 3,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
];
const sorted = sortChaptersByOrder(chapters);
expect(sorted.map((c) => c.id)).toEqual(["ch-1", "ch-2", "ch-3"]);
});
it("does not mutate input array", () => {
const chapters: TextbookChapter[] = [
{
id: "ch-2",
textbookId: "tb-001",
title: "第二章",
order: 2,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
{
id: "ch-1",
textbookId: "tb-001",
title: "第一章",
order: 1,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
];
sortChaptersByOrder(chapters);
expect(chapters[0]?.id).toBe("ch-2");
});
it("returns empty array for null/undefined", () => {
expect(sortChaptersByOrder(null)).toEqual([]);
expect(sortChaptersByOrder(undefined)).toEqual([]);
});
});
describe("formatChapterCount", () => {
it("formats positive count", () => {
expect(formatChapterCount(5)).toBe("5 章");
expect(formatChapterCount(0)).toBe("0 章");
});
it("returns 0 章 for invalid input", () => {
expect(formatChapterCount(-1)).toBe("0 章");
expect(formatChapterCount(Number.NaN)).toBe("0 章");
expect(formatChapterCount(Number.POSITIVE_INFINITY)).toBe("0 章");
});
});
describe("textbookStatusToBadgeClass", () => {
it("returns primary for PUBLISHED", () => {
expect(textbookStatusToBadgeClass("PUBLISHED")).toContain("primary");
});
it("returns amber for DEPRECATED", () => {
expect(textbookStatusToBadgeClass("DEPRECATED")).toContain("amber");
});
it("returns muted for DRAFT and ARCHIVED", () => {
expect(textbookStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(textbookStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted for unknown status", () => {
expect(textbookStatusToBadgeClass("UNKNOWN")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("chapterStatusToBadgeClass", () => {
it("returns primary for PUBLISHED", () => {
expect(chapterStatusToBadgeClass("PUBLISHED")).toContain("primary");
});
it("returns muted for DRAFT and ARCHIVED", () => {
expect(chapterStatusToBadgeClass("DRAFT")).toBe(
"bg-muted text-muted-foreground",
);
expect(chapterStatusToBadgeClass("ARCHIVED")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("isTextbookEditable / isTextbookPublished", () => {
it("DRAFT is editable but not published", () => {
expect(isTextbookEditable("DRAFT")).toBe(true);
expect(isTextbookPublished("DRAFT")).toBe(false);
});
it("PUBLISHED is published but not editable", () => {
expect(isTextbookEditable("PUBLISHED")).toBe(false);
expect(isTextbookPublished("PUBLISHED")).toBe(true);
});
it("unknown status is neither editable nor published", () => {
expect(isTextbookEditable("UNKNOWN")).toBe(false);
expect(isTextbookPublished("UNKNOWN")).toBe(false);
});
});
describe("toTextbookListItem", () => {
it("preserves all fields of textbook", () => {
const tb: Textbook = {
id: "tb-001",
title: "高中数学必修一",
subjectId: "sub-math",
gradeId: "g-10",
version: "人教版 2026",
status: "PUBLISHED",
tenantId: "tn-001",
createdAt: "2026-07-20T00:00:00Z",
updatedAt: "2026-07-21T00:00:00Z",
};
const item = toTextbookListItem(tb);
expect(item).toEqual(tb);
});
});
describe("formatChapterOrder", () => {
it("formats valid order with trailing dot", () => {
expect(formatChapterOrder(1)).toBe("1.");
expect(formatChapterOrder(10)).toBe("10.");
});
it("returns placeholder for invalid input", () => {
expect(formatChapterOrder(Number.NaN)).toBe("--");
expect(formatChapterOrder(Number.POSITIVE_INFINITY)).toBe("--");
});
});

View File

@@ -0,0 +1,244 @@
"use client";
/**
* 教材详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 单查 textbook(id: ID!):✅ schema 真实字段content 子图)
* - 章节列表 textbookChapters(textbookId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#textbook-chapters
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Book, ChevronLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTextbook, useTextbookChapters } from "@/lib/api";
import type { TextbookChapter } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
formatChapterOrder,
formatChapterStatus,
formatTextbookDate,
formatTextbookStatus,
isTextbookEditable,
sortChaptersByOrder,
} from "@/features/teacher/textbooks/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function TextbookDetailClient(): React.ReactElement {
const t = useTranslations("textbooks");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const textbookId = params?.id ?? "";
// ✅ 真实查询textbook(id: ID!)schema 已就绪
const { data, loading, error } = useTextbook(textbookId);
// @contract-pending章节列表schema 无 chapters(textbookId) → MSW 兜底
const {
data: chaptersData,
loading: chaptersLoading,
error: chaptersError,
} = useTextbookChapters(textbookId, {
enabled: Boolean(data),
});
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={Book}
title={t("detail.notFound")}
action={{
label: t("detail.noChaptersAction"),
href: "/shell/teacher/textbooks",
}}
/>
) : undefined;
return (
<DetailPageShell
title={data?.title ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatTextbookDate(data.createdAt),
})
: undefined
}
icon={<Book className="size-6" />}
backHref="/shell/teacher/textbooks"
actions={
data && isTextbookEditable(data.status) ? (
<Button variant="outline">{t("detail.edit")}</Button>
) : null
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <TextbookDetailBody textbook={data} /> : null}
{data ? (
<ChaptersSection
chapters={chaptersData?.items}
loading={chaptersLoading}
error={chaptersError}
mswNotice={t("detail.chaptersMswNotice")}
/>
) : null}
</DetailPageShell>
);
}
/**
* 详情基本信息区(对齐 §7.3 详情页模板)。
*/
function TextbookDetailBody({
textbook,
}: {
textbook: NonNullable<ReturnType<typeof useTextbook>["data"]>;
}): React.ReactElement {
const t = useTranslations("textbooks");
return (
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={textbook.title} />
<DetailField
label={t("detail.fieldSubjectId")}
value={textbook.subjectId}
/>
<DetailField label={t("detail.fieldGradeId")} value={textbook.gradeId} />
<DetailField label={t("detail.fieldVersion")} value={textbook.version} />
<DetailField
label={t("detail.fieldStatus")}
value={formatTextbookStatus(textbook.status)}
/>
<DetailField
label={t("detail.fieldTenantId")}
value={textbook.tenantId ?? "-"}
/>
<DetailField
label={t("detail.fieldCreatedAt")}
value={formatTextbookDate(textbook.createdAt)}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatTextbookDate(textbook.updatedAt)}
/>
</DetailSection>
);
}
/**
* 章节列表区(@contract-pendingMSW 兜底)。
*
* loading行级骨架
* error局部降级提示
* empty无章节时显示空态
*/
function ChaptersSection({
chapters,
loading,
error,
mswNotice,
}: {
chapters: TextbookChapter[] | undefined;
loading: boolean;
error: unknown;
mswNotice: string;
}): React.ReactElement {
const t = useTranslations("textbooks");
const tCommon = useTranslations("common");
const sorted = sortChaptersByOrder(chapters);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
</div>
) : null;
return (
<DetailSection title={t("detail.sectionChapters")}>
{errorNode}
{!error && loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-8 animate-pulse rounded bg-muted/50"
aria-hidden="true"
/>
))}
</div>
) : null}
{!error && !loading && sorted.length === 0 ? (
<Link
href="/shell/teacher/textbooks"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
{t("detail.noChapters")}
</Link>
) : null}
{!error && !loading && sorted.length > 0 ? (
<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("detail.colOrder")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colChapterTitle")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colChapterStatus")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sorted.map((ch) => (
<tr key={ch.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatChapterOrder(ch.order)}
</td>
<td className="p-3 font-medium">{ch.title}</td>
<td className="p-3 text-xs">
{formatChapterStatus(ch.status)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</DetailSection>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
/**
* 教材管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 textbooks(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 单查 textbook(id):✅ 真实字段(本页未使用,详情页使用)
* - 契约工单docs/architecture/issues/contracts/core-edu_contract.md#textbooks-list
*
* URL 状态:?subjectId=&gradeId=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Book, Plus } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useTextbooks, type TextbookListItem } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatChapterCount,
formatTextbookDate,
formatTextbookStatus,
textbookStatusToBadgeClass,
truncateTitle,
} from "@/features/teacher/textbooks/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function TextbooksListClient(): React.ReactElement {
const t = useTranslations("textbooks");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const subjectId = searchParams.get("subjectId") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useTextbooks({
subjectId: subjectId || undefined,
gradeId: gradeId || undefined,
q: q || undefined,
});
const filteredItems = useMemo<TextbookListItem[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/textbooks?${params.toString()}`);
});
};
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>
<p className="mt-2 text-xs text-muted-foreground">
{t("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={Book}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/textbooks",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<Book className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/textbooks">
<Plus className="mr-1 size-4" />
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("list.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.subjectPlaceholder")}
/>
<input
type="text"
value={gradeId}
onChange={(e) => updateQuery("gradeId", e.target.value)}
placeholder={t("list.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.gradePlaceholder")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<TextbooksTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 教材列表表格(纯展示组件,对齐 §8.2 排版规范)。
*
* 注:章节数由 MSW mock 扩展提供schema Textbook 类型无 chapters 字段)。
* 这里基于 mock 数据的 chapters 字段计算章节数;若 mock 未提供则展示 0 章。
*/
function TextbooksTable({
items,
}: {
items: TextbookListItem[];
}): React.ReactElement {
const t = useTranslations("textbooks");
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("list.colTitle")}</th>
<th className="p-3 text-left font-medium">
{t("list.colSubject")}
</th>
<th className="p-3 text-left font-medium">{t("list.colGrade")}</th>
<th className="p-3 text-left font-medium">
{t("list.colVersion")}
</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colChapters")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colCreatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((tb) => (
<tr key={tb.id} className="hover:bg-muted/30">
<td className="max-w-xs p-3">
<Link
href={`/shell/teacher/textbooks/${tb.id}`}
className="font-medium hover:underline"
>
{truncateTitle(tb.title)}
</Link>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{tb.subjectId}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{tb.gradeId}
</td>
<td className="p-3 text-xs">{tb.version}</td>
<td className="p-3">
<TextbookStatusBadge status={tb.status} />
</td>
<td className="p-3 text-xs text-muted-foreground">
{formatChapterCount(
getTextbookChapterCount(tb as TextbookListItemWithChapters),
)}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatTextbookDate(tb.createdAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/textbooks/${tb.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 教材状态徽章(按状态色阶展示)。
*/
function TextbookStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatTextbookStatus(status);
const cls = textbookStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}
/**
* 内部辅助类型MSW mock 教材数据可能附带 chapters 字段(@contract-pending
* schema Textbook 类型无 chapters 字段,列表项中的章节数由 MSW 扩展提供。
*/
interface TextbookListItemWithChapters extends TextbookListItem {
chapters?: Array<{ id: string }>;
}
/**
* 从教材列表项中提取章节数。
* MSW mock 可能提供 chapters 数组;若未提供返回 0。
*/
function getTextbookChapterCount(item: TextbookListItemWithChapters): number {
return Array.isArray(item.chapters) ? item.chapters.length : 0;
}

View File

@@ -0,0 +1,160 @@
/**
* Textbooks 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Textbook, TextbookChapter } from "@/lib/api";
/** 教材状态中文标签映射 */
export const TEXTBOOK_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
DEPRECATED: "已弃用",
ARCHIVED: "已归档",
};
/** 章节状态中文标签映射 */
export const CHAPTER_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
ARCHIVED: "已归档",
};
/**
* 将教材状态映射为中文标签。未知状态回退为原始值。
*/
export function formatTextbookStatus(status: string): string {
return TEXTBOOK_STATUS_LABEL[status] ?? status;
}
/**
* 将章节状态映射为中文标签。未知状态回退为原始值。
*/
export function formatChapterStatus(status: string): string {
return CHAPTER_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatTextbookDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 截断教材标题用于列表展示。
* - 超过 maxLen 字符时截断并加省略号
* - maxLen 默认 40
*/
export function truncateTitle(title: string, maxLen = 40): string {
const text = title.replace(/\s+/g, " ").trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen)}...`;
}
/**
* 按章节 order 升序排序(稳定排序)。
* 输入为 null/undefined 时返回空数组。
*/
export function sortChaptersByOrder(
chapters: TextbookChapter[] | null | undefined,
): TextbookChapter[] {
if (!chapters) return [];
return [...chapters].sort((a, b) => a.order - b.order);
}
/**
* 格式化章节数为展示字符串。
* 输入无效返回 "0 章"。
*/
export function formatChapterCount(count: number): string {
if (!Number.isFinite(count) || count < 0) return "0 章";
return `${count}`;
}
/**
* 根据教材状态返回 Tailwind 徽章语义类名。
*/
export function textbookStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "DEPRECATED":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 根据章节状态返回 Tailwind 徽章语义类名。
*/
export function chapterStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "PUBLISHED":
return "bg-primary/10 text-primary";
case "ARCHIVED":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 判断教材是否处于可编辑状态DRAFT
*/
export function isTextbookEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断教材是否已发布PUBLISHED
*/
export function isTextbookPublished(status: string): boolean {
return status === "PUBLISHED";
}
/**
* 从教材实体中提取列表项视图模型TextbookListItem 与 Textbook 同构)。
* 保留为显式函数以对齐 questions/homework 模式,便于未来字段裁剪。
*/
export function toTextbookListItem(textbook: Textbook): Textbook {
return {
id: textbook.id,
title: textbook.title,
subjectId: textbook.subjectId,
gradeId: textbook.gradeId,
version: textbook.version,
status: textbook.status,
tenantId: textbook.tenantId,
createdAt: textbook.createdAt,
updatedAt: textbook.updatedAt,
};
}
/**
* 格式化章节序号为展示字符串("1." / "2." 等)。
* 输入无效返回 "--"。
*/
export function formatChapterOrder(order: number): string {
if (!Number.isFinite(order)) return "--";
return `${order}.`;
}