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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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("--");
|
||||
});
|
||||
});
|
||||
@@ -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):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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;
|
||||
}
|
||||
@@ -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}.`;
|
||||
}
|
||||
Reference in New Issue
Block a user