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

@@ -19,9 +19,9 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>;
}
// Baseline as of P2 (2026-07-22, lesson-plans module added). Update when adding pages.
// Baseline as of P2 (2026-07-22, questions + textbooks modules added). Update when adding pages.
const BASELINE: Baseline = {
total: 37,
total: 40,
categories: {
dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx",

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 题库路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function QuestionsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("questions");
useEffect(() => {
console.error("[portal-shell] questions route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 题库路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function QuestionsLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { QuestionsListClient } from "@/features/teacher/questions/questions-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 题库管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 QuestionsListClientclient component中。
*
* 数据契约:列表查询 questions(...) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#questions-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function QuestionsListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<QuestionsListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { TextbookDetailClient } from "@/features/teacher/textbooks/textbook-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 教材详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹。
* 业务逻辑在 TextbookDetailClientclient component中。
*
* 数据契约:
* - 单查 textbook(id: ID!) ✅ schema 真实字段content 子图)
* - 章节列表 textbookChapters(textbookId) ❌ schema 无 → MSW 兜底(@contract-pending
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function TextbookDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<TextbookDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 教材路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常含详情页
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function TextbooksError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("textbooks");
useEffect(() => {
console.error("[portal-shell] textbooks route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 教材路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*
* 子页面(详情)的 Skeleton 由其 server page 的 <Suspense> 兜底,
* 本文件仅在 /shell/teacher/textbooks 列表/重定向期间显示。
*/
export default function TextbooksLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { TextbooksListClient } from "@/features/teacher/textbooks/textbooks-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 教材管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 TextbooksListClientclient component中。
*
* 数据契约:列表查询 textbooks(...) ❌ schema 无此字段 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#textbooks-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function TextbooksListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<TextbooksListClient />
</Suspense>
);
}

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}.`;
}

View File

@@ -18,6 +18,8 @@ export * from "./exams";
export * from "./homework";
export * from "./grades";
export * from "./lesson-plans";
export * from "./questions";
export * from "./textbooks";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -8,6 +8,8 @@ export * from "./exams.graphql";
export * from "./homework.graphql";
export * from "./grades.graphql";
export * from "./lesson-plans.graphql";
export * from "./questions.graphql";
export * from "./textbooks.graphql";
export * from "./student.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";

View File

@@ -0,0 +1,105 @@
// Questions domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetQuestion按 id 单查):✅ combined-schema 中真实存在question(id: ID!): Question
// - GetQuestions列表查询❌ schema 无 questions(...) 根字段
// → 走 MSW 兜底(@contract-pending等待后端补齐列表契约
// - Create/Update/DeleteQuestionmutation❌ schema 无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐 mutation 契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#questions
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询question(id) 单查 ─────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 Question 类型content 子图)
export const GET_QUESTION_DOC = gql`
query GetQuestion($id: ID!) {
question(id: $id) {
id
knowledgePointId
type
content
answer
explanation
difficulty
status
source
createdBy
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 questions(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#questions-list
export const GET_QUESTIONS_DOC = gql`
query GetQuestions(
$type: String
$difficulty: String
$subjectId: String
$textbookId: String
$q: String
$limit: Int
$offset: Int
) {
questions(
type: $type
difficulty: $difficulty
subjectId: $subjectId
textbookId: $textbookId
q: $q
limit: $limit
offset: $offset
) {
items {
id
type
content
difficulty
status
source
knowledgePointId
subjectId
textbookId
createdAt
}
total
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建题目schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-question-mutation
export const CREATE_QUESTION_DOC = gql`
mutation CreateQuestion($input: CreateQuestionInput!) {
createQuestion(input: $input) {
id
}
}
`;
// 更新题目schema 无 Mutation 类型
// 契约工单core-edu_contract.md#update-question-mutation
export const UPDATE_QUESTION_DOC = gql`
mutation UpdateQuestion($id: ID!, $input: UpdateQuestionInput!) {
updateQuestion(id: $id, input: $input) {
id
}
}
`;
// 删除题目schema 无 Mutation 类型
// 契约工单core-edu_contract.md#delete-question-mutation
export const DELETE_QUESTION_DOC = gql`
mutation DeleteQuestion($id: ID!) {
deleteQuestion(id: $id) {
id
}
}
`;

View File

@@ -26,8 +26,10 @@ export const SAVE_LESSON_PLAN_DOC = gql`
`;
// From widgets/teacher/question-bank
export const GET_QUESTIONS_DOC = gql`
query GetQuestions($bankId: ID!, $type: String, $limit: Int) {
// 注:重命名为 GET_QUESTION_BANK_DOC 以避免与 questions.graphql.ts 的 GET_QUESTIONS_DOC 冲突
// P2 迁移questions.graphql.ts 对齐 schemateacher.graphql.ts 保留旧 widget 契约)
export const GET_QUESTION_BANK_DOC = gql`
query GetQuestionBank($bankId: ID!, $type: String, $limit: Int) {
questions(bankId: $bankId, type: $type, limit: $limit) {
id
type
@@ -41,8 +43,10 @@ export const GET_QUESTIONS_DOC = gql`
`;
// From widgets/teacher/textbook-manager
export const GET_TEXTBOOKS_DOC = gql`
query GetTextbooks($subjectId: ID, $grade: String) {
// 注:重命名为 GET_LEGACY_TEXTBOOKS_DOC 以避免与 textbooks.graphql.ts 的 GET_TEXTBOOKS_DOC 冲突
// P2 迁移textbooks.graphql.ts 对齐 schemateacher.graphql.ts 保留旧 widget 契约)
export const GET_LEGACY_TEXTBOOKS_DOC = gql`
query GetLegacyTextbooks($subjectId: ID, $grade: String) {
textbooks(subjectId: $subjectId, grade: $grade) {
id
title

View File

@@ -0,0 +1,111 @@
// Textbooks domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetTextbook按 id 单查):✅ combined-schema 中真实存在textbook(id: ID!): Textbook
// - GetTextbooks列表查询❌ schema 无 textbooks(...) 根字段
// → 走 MSW 兜底(@contract-pending等待后端补齐列表契约
// - GetTextbookChapters章节列表❌ schema 无 chapters(textbookId) 根字段
// → 走 MSW 兜底(@contract-pendingTextbook 类型无 chapters 字段
// - Create/UpdateTextbookmutation❌ schema 无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐 mutation 契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#textbooks
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询textbook(id) 单查 ─────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 Textbook 类型content 子图)
export const GET_TEXTBOOK_DOC = gql`
query GetTextbook($id: ID!) {
textbook(id: $id) {
id
title
subjectId
gradeId
version
status
tenantId
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 textbooks(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#textbooks-list
export const GET_TEXTBOOKS_DOC = gql`
query GetTextbooks(
$subjectId: String
$gradeId: String
$q: String
$limit: Int
$offset: Int
) {
textbooks(
subjectId: $subjectId
gradeId: $gradeId
q: $q
limit: $limit
offset: $offset
) {
items {
id
title
subjectId
gradeId
version
status
tenantId
createdAt
updatedAt
}
total
}
}
`;
// ── 章节列表查询(@contract-pending───────────────────────────
// schema 无 chapters(textbookId) 根字段Textbook 类型也无 chapters 字段
// 详情页章节列表通过 MSW 兜底,后端补齐后切换 fetcher
// 契约工单core-edu_contract.md#textbook-chapters
export const GET_TEXTBOOK_CHAPTERS_DOC = gql`
query GetTextbookChapters($textbookId: ID!) {
textbookChapters(textbookId: $textbookId) {
items {
id
textbookId
title
order
parentId
status
createdAt
updatedAt
}
total
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建教材schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-textbook-mutation
export const CREATE_TEXTBOOK_DOC = gql`
mutation CreateTextbook($input: CreateTextbookInput!) {
createTextbook(input: $input) {
id
}
}
`;
// 更新教材schema 无 Mutation 类型
// 契约工单core-edu_contract.md#update-textbook-mutation
export const UPDATE_TEXTBOOK_DOC = gql`
mutation UpdateTextbook($id: ID!, $input: UpdateTextbookInput!) {
updateTextbook(id: $id, input: $input) {
id
}
}
`;

View File

@@ -0,0 +1,315 @@
"use client";
/**
* Questions domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域题库模块)
*
* 三类操作:
* 1. useQuestion按 id 单查):✅ 真实查询 question(id: ID!)schema 已就绪
* 2. useQuestions列表查询❌ schema 无 questions(...) 根字段 → MSW 兜底(@contract-pending
* 3. useCreate/useUpdate/useDeleteQuestionmutation❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#questions
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
CREATE_QUESTION_DOC,
DELETE_QUESTION_DOC,
GET_QUESTION_DOC,
GET_QUESTIONS_DOC,
UPDATE_QUESTION_DOC,
} from "./operations/questions.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 schema Question 类型)=====
/**
* 题目实体(对齐 combined-schema.graphql Question 类型content 子图)
*
* 字段命名 camelCase与 schema 一致)。
* 注schema Question 没有 subjectId/textbookId 字段,列表项中的这两个字段
* 由 MSW mock 数据扩展提供(@contract-pending后端补齐列表契约时同步对齐。
*/
export interface Question {
id: string;
knowledgePointId: string;
type: string;
content: string;
answer: string;
explanation: string | null;
difficulty: number;
status: string;
source: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/**
* 题目列表项(轻量字段集,用于列表渲染)
*
* difficulty 在 schema 中是 Float但业务列表展示常用枚举字符串easy/medium/hard
* MSW mock 列表数据使用字符串枚举;真实单查返回 Float。
* 调用方按需做类型转换transformations.formatDifficulty 同时支持数值与字符串。
*/
export interface QuestionListItem {
id: string;
type: string;
content: string;
difficulty: string;
status: string;
source: string;
knowledgePointId: string;
/** @contract-pending 列表扩展字段MSW 提供,后端补齐后对齐 */
subjectId: string | null;
/** @contract-pending 列表扩展字段MSW 提供,后端补齐后对齐 */
textbookId: string | null;
createdAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface QuestionsListResponse {
questions: {
items: QuestionListItem[];
total: number;
};
}
/** 单查响应(真实 schema */
interface QuestionResponse {
question: Question | null;
}
/** 列表筛选条件 */
export interface QuestionsListFilter {
type?: string;
difficulty?: string;
subjectId?: string;
textbookId?: string;
q?: string;
limit?: number;
offset?: number;
}
/** 新建题目输入 */
export interface CreateQuestionInput {
type: string;
content: string;
answer: string;
explanation?: string;
difficulty: number;
knowledgePointId: string;
source?: string;
}
/** 更新题目输入 */
export interface UpdateQuestionInput {
type?: string;
content?: string;
answer?: string;
explanation?: string;
difficulty?: number;
status?: string;
}
/** 新建题目 mutation 响应(@contract-pending */
interface CreateQuestionResponse {
createQuestion: { id: string } | null;
}
/** 更新题目 mutation 响应(@contract-pending */
interface UpdateQuestionResponse {
updateQuestion: { id: string } | null;
}
/** 删除题目 mutation 响应(@contract-pending */
interface DeleteQuestionResponse {
deleteQuestion: { id: string } | null;
}
// ===== 查询选项 =====
export interface QuestionQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询题目详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
*/
export function useQuestion(
id: string,
options?: QuestionQueryOptions,
): UseQueryResult<Question | null> {
const result = useWidgetQuery<QuestionResponse, { id: string }>(
GET_QUESTION_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.question ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询题目列表(@contract-pendingMSW 兜底)。
*
* schema 无 questions(...) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useQuestions(
filter: QuestionsListFilter,
options?: QuestionQueryOptions,
): UseQueryResult<{ items: QuestionListItem[]; total: number }> {
const result = useWidgetQuery<
QuestionsListResponse,
{
type?: string;
difficulty?: string;
subjectId?: string;
textbookId?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_QUESTIONS_DOC,
{
type: filter.type,
difficulty: filter.difficulty,
subjectId: filter.subjectId,
textbookId: filter.textbookId,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.questions,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建题目 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useCreateQuestion(): {
run: (input: CreateQuestionInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateQuestionResponse, { input: CreateQuestionInput }>(
CREATE_QUESTION_DOC,
);
const run = async (input: CreateQuestionInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createQuestion) {
throw new ApiError("Failed to create question", "INTERNAL_ERROR");
}
return data.createQuestion;
};
return { run, loading, error };
}
/**
* 更新题目 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useUpdateQuestion(): {
run: (id: string, input: UpdateQuestionInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateQuestionResponse,
{ id: string; input: UpdateQuestionInput }
>(UPDATE_QUESTION_DOC);
const run = async (
id: string,
input: UpdateQuestionInput,
): Promise<{ id: string }> => {
const data = await rawRun({ id, input });
if (!data?.updateQuestion) {
throw new ApiError("Failed to update question", "INTERNAL_ERROR");
}
return data.updateQuestion;
};
return { run, loading, error };
}
/**
* 删除题目 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useDeleteQuestion(): {
run: (id: string) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<DeleteQuestionResponse, { id: string }>(
DELETE_QUESTION_DOC,
);
const run = async (id: string): Promise<{ id: string }> => {
const data = await rawRun({ id });
if (!data?.deleteQuestion) {
throw new ApiError("Failed to delete question", "INTERNAL_ERROR");
}
return data.deleteQuestion;
};
return { run, loading, error };
}

View File

@@ -15,8 +15,8 @@ import type { UseQueryResult } from "./types";
import {
GET_LESSON_PLANS_DOC,
SAVE_LESSON_PLAN_DOC,
GET_QUESTIONS_DOC,
GET_TEXTBOOKS_DOC,
GET_QUESTION_BANK_DOC,
GET_LEGACY_TEXTBOOKS_DOC,
GET_SCHEDULING_RULES_DOC,
UPDATE_SCHEDULING_RULE_DOC,
} from "./operations/teacher.graphql";
@@ -98,9 +98,9 @@ export function useSaveLessonPlan(): {
return { run, loading, error };
}
// ===== Question Bank =====
// ===== Question Bank (legacy widget API, P2 迁移后由 questions.ts 取代) =====
export interface Question {
export interface QuestionBankItem {
id: string;
type: string;
difficulty: string;
@@ -116,19 +116,21 @@ export interface QuestionBankFilter {
}
/**
* 查询题库下的题目列表。
* 查询题库下的题目列表legacy widget API
* bankId 为空时自动跳过查询。
*
* 注P2 迁移后新页面使用 questions.ts 的 useQuestions对齐 schema
*/
export function useQuestionBank(
bankId: string,
filter?: QuestionBankFilter,
options?: TeacherQueryOptions<Question[]>,
): UseQueryResult<Question[]> {
options?: TeacherQueryOptions<QuestionBankItem[]>,
): UseQueryResult<QuestionBankItem[]> {
const result = useWidgetQuery<
{ questions: Question[] },
{ questions: QuestionBankItem[] },
{ bankId: string; type?: string; limit?: number }
>(
GET_QUESTIONS_DOC,
GET_QUESTION_BANK_DOC,
{ bankId, type: filter?.type, limit: filter?.limit },
{ ...options, enabled: options?.enabled ?? bankId.length > 0 },
);
@@ -138,39 +140,41 @@ export function useQuestionBank(
};
}
// ===== Textbook =====
// ===== Textbook (legacy widget API, P2 迁移后由 textbooks.ts 取代) =====
export interface Chapter {
export interface LegacyChapter {
id: string;
title: string;
}
export interface Textbook {
export interface LegacyTextbook {
id: string;
title: string;
author: string;
publisher: string;
isbn: string;
chapters: Chapter[];
chapters: LegacyChapter[];
}
export interface TextbookFilter {
export interface LegacyTextbookFilter {
subjectId?: string;
grade?: string;
}
/**
* 查询教材列表,可按科目与年级筛选。
* 查询教材列表legacy widget API,可按科目与年级筛选。
*
* 注P2 迁移后新页面使用 textbooks.ts 的 useTextbooks对齐 schema
*/
export function useTextbooks(
filter?: TextbookFilter,
options?: TeacherQueryOptions<Textbook[]>,
): UseQueryResult<Textbook[]> {
export function useLegacyTextbooks(
filter?: LegacyTextbookFilter,
options?: TeacherQueryOptions<LegacyTextbook[]>,
): UseQueryResult<LegacyTextbook[]> {
const result = useWidgetQuery<
{ textbooks: Textbook[] },
{ textbooks: LegacyTextbook[] },
{ subjectId?: string; grade?: string }
>(
GET_TEXTBOOKS_DOC,
GET_LEGACY_TEXTBOOKS_DOC,
{ subjectId: filter?.subjectId, grade: filter?.grade },
options,
);

View File

@@ -0,0 +1,302 @@
"use client";
/**
* Textbooks domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域教材模块)
*
* 三类操作:
* 1. useTextbook按 id 单查):✅ 真实查询 textbook(id: ID!)schema 已就绪
* 2. useTextbooks列表查询❌ schema 无 textbooks(...) 根字段 → MSW 兜底(@contract-pending
* 3. useTextbookChapters章节列表❌ schema 无 chapters(textbookId) → MSW 兜底(@contract-pending
* 4. useCreate/useUpdateTextbookmutation❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#textbooks
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
CREATE_TEXTBOOK_DOC,
GET_TEXTBOOK_CHAPTERS_DOC,
GET_TEXTBOOK_DOC,
GET_TEXTBOOKS_DOC,
UPDATE_TEXTBOOK_DOC,
} from "./operations/textbooks.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 schema Textbook / Chapter 类型)=====
/**
* 教材实体(对齐 combined-schema.graphql Textbook 类型content 子图)
*
* 字段命名 camelCase与 schema 一致)。
*/
export interface Textbook {
id: string;
title: string;
subjectId: string;
gradeId: string;
version: string;
status: string;
tenantId: string | null;
createdAt: string;
updatedAt: string;
}
/** 教材列表项(与 Textbook 同构,列表页直接复用) */
export type TextbookListItem = Textbook;
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface TextbooksListResponse {
textbooks: {
items: TextbookListItem[];
total: number;
};
}
/** 单查响应(真实 schema */
interface TextbookResponse {
textbook: Textbook | null;
}
/**
* 章节实体(对齐 combined-schema.graphql Chapter 类型content 子图)
*
* schema Chapter 字段id/textbookId/title/order/parentId/status/createdAt/updatedAt
*/
export interface TextbookChapter {
id: string;
textbookId: string;
title: string;
order: number;
parentId: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
/** 章节列表查询响应(@contract-pending */
interface TextbookChaptersResponse {
textbookChapters: {
items: TextbookChapter[];
total: number;
};
}
/** 列表筛选条件 */
export interface TextbooksListFilter {
subjectId?: string;
gradeId?: string;
q?: string;
limit?: number;
offset?: number;
}
/** 新建教材输入 */
export interface CreateTextbookInput {
title: string;
subjectId: string;
gradeId: string;
version: string;
status?: string;
}
/** 更新教材输入 */
export interface UpdateTextbookInput {
title?: string;
subjectId?: string;
gradeId?: string;
version?: string;
status?: string;
}
/** 新建教材 mutation 响应(@contract-pending */
interface CreateTextbookResponse {
createTextbook: { id: string } | null;
}
/** 更新教材 mutation 响应(@contract-pending */
interface UpdateTextbookResponse {
updateTextbook: { id: string } | null;
}
// ===== 查询选项 =====
export interface TextbookQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询教材详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 详情页
*/
export function useTextbook(
id: string,
options?: TextbookQueryOptions,
): UseQueryResult<Textbook | null> {
const result = useWidgetQuery<TextbookResponse, { id: string }>(
GET_TEXTBOOK_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.textbook ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询教材列表(@contract-pendingMSW 兜底)。
*
* schema 无 textbooks(...) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useTextbooks(
filter: TextbooksListFilter,
options?: TextbookQueryOptions,
): UseQueryResult<{ items: TextbookListItem[]; total: number }> {
const result = useWidgetQuery<
TextbooksListResponse,
{
subjectId?: string;
gradeId?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_TEXTBOOKS_DOC,
{
subjectId: filter.subjectId,
gradeId: filter.gradeId,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.textbooks,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询教材章节列表(@contract-pendingMSW 兜底)。
*
* schema 无 chapters(textbookId) 根字段Textbook 类型也无 chapters 字段。
* 详情页章节列表通过 MSW 返回 mock 数据,后端补齐后切换 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useTextbookChapters(
textbookId: string,
options?: TextbookQueryOptions,
): UseQueryResult<{ items: TextbookChapter[]; total: number }> {
const result = useWidgetQuery<
TextbookChaptersResponse,
{ textbookId: string }
>(
GET_TEXTBOOK_CHAPTERS_DOC,
{ textbookId },
{
...options,
enabled: options?.enabled ?? textbookId.length > 0,
},
);
return {
data: result.data?.textbookChapters,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建教材 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useCreateTextbook(): {
run: (input: CreateTextbookInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateTextbookResponse, { input: CreateTextbookInput }>(
CREATE_TEXTBOOK_DOC,
);
const run = async (input: CreateTextbookInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createTextbook) {
throw new ApiError("Failed to create textbook", "INTERNAL_ERROR");
}
return data.createTextbook;
};
return { run, loading, error };
}
/**
* 更新教材 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useUpdateTextbook(): {
run: (id: string, input: UpdateTextbookInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateTextbookResponse,
{ id: string; input: UpdateTextbookInput }
>(UPDATE_TEXTBOOK_DOC);
const run = async (
id: string,
input: UpdateTextbookInput,
): Promise<{ id: string }> => {
const data = await rawRun({ id, input });
if (!data?.updateTextbook) {
throw new ApiError("Failed to update textbook", "INTERNAL_ERROR");
}
return data.updateTextbook;
};
return { run, loading, error };
}

View File

@@ -655,10 +655,99 @@
"title": "Attendance"
},
"questions": {
"title": "Questions"
"title": "Questions",
"list": {
"title": "Question Bank",
"description": "View and manage all questions",
"new": "New Question",
"searchPlaceholder": "Search question content...",
"typeFilter": "Filter by type",
"typeAll": "All types",
"typeSingleChoice": "Single Choice",
"typeMultipleChoice": "Multiple Choice",
"typeFillBlank": "Fill in Blank",
"typeShortAnswer": "Short Answer",
"typeEssay": "Essay",
"typeTrueFalse": "True / False",
"difficultyFilter": "Filter by difficulty",
"difficultyAll": "All difficulties",
"difficultyEasy": "Easy",
"difficultyMedium": "Medium",
"difficultyHard": "Hard",
"subjectPlaceholder": "Subject ID",
"textbookPlaceholder": "Textbook ID",
"total": "{count} item(s)",
"colContent": "Content",
"colType": "Type",
"colDifficulty": "Difficulty",
"colSubject": "Subject",
"colTextbook": "Textbook",
"colCreatedAt": "Created At",
"colActions": "Actions",
"viewDetail": "View →",
"emptyTitle": "No questions yet",
"emptyDescription": "The question bank is empty. Create the first question to get started.",
"emptyAction": "New Question",
"mswNotice": "List query contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"error": {
"title": "Question page error",
"unknown": "Unknown error",
"retry": "Retry"
}
},
"textbooks": {
"title": "Textbooks"
"title": "Textbooks",
"list": {
"title": "Textbook Management",
"description": "View and manage all textbooks",
"new": "New Textbook",
"searchPlaceholder": "Search textbook title...",
"subjectPlaceholder": "Subject ID",
"gradePlaceholder": "Grade ID",
"total": "{count} item(s)",
"colTitle": "Title",
"colSubject": "Subject",
"colGrade": "Grade",
"colVersion": "Version",
"colStatus": "Status",
"colChapters": "Chapters",
"colCreatedAt": "Created At",
"colActions": "Actions",
"viewDetail": "View detail →",
"emptyTitle": "No textbooks yet",
"emptyDescription": "The textbook library is empty. Create the first textbook to get started.",
"emptyAction": "New Textbook",
"chapterCount": "{count} chapter(s)",
"mswNotice": "List query contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled."
},
"detail": {
"title": "Textbook Detail",
"edit": "Edit",
"notFound": "Textbook not found, it may have been deleted",
"createdAtPrefix": "Created at {date}",
"sectionBasic": "Basic Info",
"sectionChapters": "Chapters",
"fieldTitle": "Title",
"fieldSubjectId": "Subject ID",
"fieldGradeId": "Grade ID",
"fieldVersion": "Version",
"fieldStatus": "Status",
"fieldTenantId": "Tenant ID",
"fieldCreatedAt": "Created At",
"fieldUpdatedAt": "Updated At",
"colOrder": "Order",
"colChapterTitle": "Chapter Title",
"colChapterStatus": "Status",
"noChapters": "No chapters",
"noChaptersAction": "Back to textbook list",
"chaptersMswNotice": "Chapter list query contract pending, currently served by MSW."
},
"error": {
"title": "Textbook page error",
"unknown": "Unknown error",
"retry": "Retry"
}
},
"lessonPlans": {
"title": "Lesson Plans",

View File

@@ -655,10 +655,99 @@
"title": "考勤管理"
},
"questions": {
"title": "题库"
"title": "题库",
"list": {
"title": "题库管理",
"description": "查看和管理所有题目",
"new": "新建题目",
"searchPlaceholder": "搜索题干...",
"typeFilter": "按题型筛选",
"typeAll": "全部题型",
"typeSingleChoice": "单选题",
"typeMultipleChoice": "多选题",
"typeFillBlank": "填空题",
"typeShortAnswer": "简答题",
"typeEssay": "论述题",
"typeTrueFalse": "判断题",
"difficultyFilter": "按难度筛选",
"difficultyAll": "全部难度",
"difficultyEasy": "简单",
"difficultyMedium": "中等",
"difficultyHard": "困难",
"subjectPlaceholder": "学科 ID",
"textbookPlaceholder": "教材 ID",
"total": "共 {count} 条",
"colContent": "题干",
"colType": "题型",
"colDifficulty": "难度",
"colSubject": "学科",
"colTextbook": "教材",
"colCreatedAt": "创建时间",
"colActions": "操作",
"viewDetail": "查看 →",
"emptyTitle": "暂无题目",
"emptyDescription": "题库为空,新建第一条题目开始管理",
"emptyAction": "新建题目",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"error": {
"title": "题库页面出错了",
"unknown": "未知错误",
"retry": "重试"
}
},
"textbooks": {
"title": "教材"
"title": "教材",
"list": {
"title": "教材管理",
"description": "查看和管理所有教材",
"new": "新建教材",
"searchPlaceholder": "搜索教材名称...",
"subjectPlaceholder": "学科 ID",
"gradePlaceholder": "年级 ID",
"total": "共 {count} 条",
"colTitle": "书名",
"colSubject": "学科",
"colGrade": "年级",
"colVersion": "版本",
"colStatus": "状态",
"colChapters": "章节数",
"colCreatedAt": "创建时间",
"colActions": "操作",
"viewDetail": "查看详情 →",
"emptyTitle": "暂无教材",
"emptyDescription": "教材库为空,新建第一本教材开始管理",
"emptyAction": "新建教材",
"chapterCount": "{count} 章",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。"
},
"detail": {
"title": "教材详情",
"edit": "编辑",
"notFound": "未找到教材,可能已被删除",
"createdAtPrefix": "创建于 {date}",
"sectionBasic": "基本信息",
"sectionChapters": "章节列表",
"fieldTitle": "书名",
"fieldSubjectId": "学科 ID",
"fieldGradeId": "年级 ID",
"fieldVersion": "版本",
"fieldStatus": "状态",
"fieldTenantId": "租户 ID",
"fieldCreatedAt": "创建时间",
"fieldUpdatedAt": "更新时间",
"colOrder": "序号",
"colChapterTitle": "章节标题",
"colChapterStatus": "状态",
"noChapters": "暂无章节",
"noChaptersAction": "返回教材列表",
"chaptersMswNotice": "章节列表查询契约待补齐,当前通过 MSW 兜底。"
},
"error": {
"title": "教材页面出错了",
"unknown": "未知错误",
"retry": "重试"
}
},
"lessonPlans": {
"title": "备课",

View File

@@ -190,61 +190,246 @@ const mockUsers = {
total: 5,
};
// ── Questions 域(教师域 P2 迁移,@contract-pending 列表 + 真实单查)──
// 用于 /shell/teacher/questions 列表页 MSW 兜底 + question(id) 单查 dev 兜底
// QuestionListItem 字段id/type/content/difficulty/status/source/knowledgePointId/subjectId/textbookId/createdAt
// 注subjectId/textbookId 为 MSW 扩展字段schema Question 无此二字段)
const mockQuestions = [
{
id: "q-001",
type: "single_choice",
difficulty: "easy",
content: "下列哪个是质数?",
options: ["4", "7", "9", "15"],
answer: "B",
tags: ["数论", "质数"],
status: "PUBLISHED",
source: "人教版必修一",
knowledgePointId: "kp-001",
subjectId: "sub-math",
textbookId: "tb-001",
createdAt: "2026-07-15T08:00:00Z",
},
{
id: "q-002",
type: "multiple_choice",
difficulty: "medium",
content: "下列哪些是偶数?",
options: ["2", "3", "4", "5"],
answer: "AC",
tags: ["数论", "偶数"],
status: "PUBLISHED",
source: "人教版必修一",
knowledgePointId: "kp-002",
subjectId: "sub-math",
textbookId: "tb-001",
createdAt: "2026-07-16T09:30:00Z",
},
{
id: "q-003",
type: "fill_blank",
difficulty: "hard",
content: "sin(30°) = ?",
options: null,
answer: "0.5",
tags: ["三角函数"],
status: "DRAFT",
source: "自编",
knowledgePointId: "kp-003",
subjectId: "sub-math",
textbookId: "tb-001",
createdAt: "2026-07-17T14:00:00Z",
},
{
id: "q-004",
type: "short_answer",
difficulty: "medium",
content: "简述函数的定义及其三要素。",
status: "PUBLISHED",
source: "人教版必修一",
knowledgePointId: "kp-004",
subjectId: "sub-math",
textbookId: "tb-001",
createdAt: "2026-07-18T10:15:00Z",
},
{
id: "q-005",
type: "essay",
difficulty: "hard",
content: "论述集合在数学中的基础作用。",
status: "PUBLISHED",
source: "自编",
knowledgePointId: "kp-001",
subjectId: "sub-math",
textbookId: "tb-001",
createdAt: "2026-07-19T16:45:00Z",
},
{
id: "q-006",
type: "true_false",
difficulty: "easy",
content: "所有偶数都是合数。",
status: "ARCHIVED",
source: "人教版必修一",
knowledgePointId: "kp-002",
subjectId: "sub-math",
textbookId: "tb-002",
createdAt: "2026-07-10T11:00:00Z",
},
];
// mockQuestionDetailquestion(id) 单查 dev 兜底数据,对齐 schema Question 类型
// 字段id/knowledgePointId/type/content/answer/explanation/difficulty/status/source/createdBy/createdAt/updatedAt
const mockQuestionDetail = {
id: "q-001",
knowledgePointId: "kp-001",
type: "single_choice",
content: "下列哪个是质数?",
answer: "B",
explanation: "7 只能被 1 和自身整除是质数4=2×2, 9=3×3, 15=3×5 均为合数。",
difficulty: 0.3,
status: "PUBLISHED",
source: "人教版必修一",
createdBy: "usr-teacher-001",
createdAt: "2026-07-15T08:00:00Z",
updatedAt: "2026-07-15T08:00:00Z",
};
// ── Textbooks 域(教师域 P2 迁移,@contract-pending 列表 + 真实单查)──
// 用于 /shell/teacher/textbooks 列表页 MSW 兜底 + textbook(id) 单查 dev 兜底
// TextbookListItem 字段(对齐 schema Textbookid/title/subjectId/gradeId/version/status/tenantId/createdAt/updatedAt
// chapters 为 MSW 扩展字段schema Textbook 无 chapters列表页章节数列使用
const mockTextbooks = [
{
id: "tb-001",
title: "高中数学必修一",
author: "人民教育出版社",
publisher: "人教版",
isbn: "978-7-107-000001",
chapters: [
{ id: "ch-001", title: "第一章 集合与函数" },
{ id: "ch-002", title: "第二章 基本初等函数" },
],
subjectId: "sub-math",
gradeId: "g-10",
version: "人教版 2026",
status: "PUBLISHED",
tenantId: "tn-001",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-10T00:00:00Z",
chapters: [{ id: "ch-001" }, { id: "ch-002" }, { id: "ch-003" }],
},
{
id: "tb-002",
title: "高中物理必修一",
author: "人民教育出版社",
publisher: "人教版",
isbn: "978-7-107-000002",
subjectId: "sub-physics",
gradeId: "g-10",
version: "人教版 2026",
status: "PUBLISHED",
tenantId: "tn-001",
createdAt: "2026-06-02T00:00:00Z",
updatedAt: "2026-07-05T00:00:00Z",
chapters: [{ id: "ch-101" }, { id: "ch-102" }],
},
{
id: "tb-003",
title: "高中化学必修一",
subjectId: "sub-chemistry",
gradeId: "g-10",
version: "鲁科版 2026",
status: "DRAFT",
tenantId: "tn-001",
createdAt: "2026-06-10T00:00:00Z",
updatedAt: "2026-06-20T00:00:00Z",
chapters: [],
},
{
id: "tb-004",
title: "高中数学必修二",
subjectId: "sub-math",
gradeId: "g-10",
version: "人教版 2026",
status: "DEPRECATED",
tenantId: "tn-001",
createdAt: "2025-08-01T00:00:00Z",
updatedAt: "2026-01-15T00:00:00Z",
chapters: [
{ id: "ch-003", title: "第一章 运动的描述" },
{ id: "ch-004", title: "第二章 匀变速直线运动" },
{ id: "ch-201" },
{ id: "ch-202" },
{ id: "ch-203" },
{ id: "ch-204" },
],
},
];
// mockTextbookDetailtextbook(id) 单查 dev 兜底数据,对齐 schema Textbook 类型
const mockTextbookDetail = {
id: "tb-001",
title: "高中数学必修一",
subjectId: "sub-math",
gradeId: "g-10",
version: "人教版 2026",
status: "PUBLISHED",
tenantId: "tn-001",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-10T00:00:00Z",
};
// mockTextbookChapters按 textbookId 索引的章节列表(@contract-pending
// 对齐 schema Chapter 类型id/textbookId/title/order/parentId/status/createdAt/updatedAt
const mockTextbookChapters: Record<
string,
Array<{
id: string;
textbookId: string;
title: string;
order: number;
parentId: string | null;
status: string;
createdAt: string;
updatedAt: string;
}>
> = {
"tb-001": [
{
id: "ch-001",
textbookId: "tb-001",
title: "第一章 集合与函数概念",
order: 1,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-15T00:00:00Z",
},
{
id: "ch-002",
textbookId: "tb-001",
title: "第二章 基本初等函数",
order: 2,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-20T00:00:00Z",
},
{
id: "ch-003",
textbookId: "tb-001",
title: "第三章 函数的应用",
order: 3,
parentId: null,
status: "DRAFT",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
},
],
"tb-002": [
{
id: "ch-101",
textbookId: "tb-002",
title: "第一章 运动的描述",
order: 1,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-06-02T00:00:00Z",
updatedAt: "2026-06-15T00:00:00Z",
},
{
id: "ch-102",
textbookId: "tb-002",
title: "第二章 匀变速直线运动",
order: 2,
parentId: null,
status: "PUBLISHED",
createdAt: "2026-06-02T00:00:00Z",
updatedAt: "2026-06-25T00:00:00Z",
},
],
};
// ── Exams 域(@contract-pendingschema 无 exams 列表/createExam mutation
// 用于 /shell/teacher/exams 列表页 + /new 表单页 MSW 兜底
const mockExams = [
@@ -1556,11 +1741,221 @@ export function graphqlResponse(
},
};
// ── Exams 域 ──
case "GetQuestions":
return { data: { questions: mockQuestions } };
case "GetTextbooks":
return { data: { textbooks: mockTextbooks } };
// ── Questions 域(教师域 P2 迁移,@contract-pending 列表 + 真实单查)──
// GetQuestion($id):按 id 单查,任意 id 都返回 mockQuestionDetaildev 兜底)
case "GetQuestion": {
const qId = (variables?.id as string | undefined) ?? "";
const found = mockQuestions.find((q) => q.id === qId);
// 真实 schema 单查返回完整 Question 字段(含 answer/explanation/createdBy 等)
return {
data: {
question: found
? {
...mockQuestionDetail,
...found,
difficulty:
typeof found.difficulty === "string"
? found.difficulty === "easy"
? 0.3
: found.difficulty === "medium"
? 0.5
: found.difficulty === "hard"
? 0.8
: 0.5
: found.difficulty,
answer: mockQuestionDetail.answer,
explanation: mockQuestionDetail.explanation,
createdBy: mockQuestionDetail.createdBy,
updatedAt: found.createdAt,
}
: { ...mockQuestionDetail, id: qId },
},
};
}
// GetQuestions($type, $difficulty, $subjectId, $textbookId, $q):列表查询
case "GetQuestions": {
const type = variables?.type as string | undefined;
const difficulty = variables?.difficulty as string | undefined;
const subjectId = variables?.subjectId as string | undefined;
const textbookId = variables?.textbookId as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockQuestions];
if (type) filtered = filtered.filter((item) => item.type === type);
if (difficulty)
filtered = filtered.filter((item) => item.difficulty === difficulty);
if (subjectId)
filtered = filtered.filter((item) => item.subjectId === subjectId);
if (textbookId)
filtered = filtered.filter((item) => item.textbookId === textbookId);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter((item) =>
item.content.toLowerCase().includes(ql),
);
}
return {
data: {
questions: { items: filtered, total: filtered.length },
},
};
}
// CreateQuestion($input)mutation 兜底
case "CreateQuestion": {
const newId = `q-${Date.now()}`;
return { data: { createQuestion: { id: newId } } };
}
// UpdateQuestion($id, $input)mutation 兜底
case "UpdateQuestion": {
const id = (variables?.id as string) ?? "";
return { data: { updateQuestion: { id } } };
}
// DeleteQuestion($id)mutation 兜底
case "DeleteQuestion": {
const id = (variables?.id as string) ?? "";
return { data: { deleteQuestion: { id } } };
}
// ── Textbooks 域(教师域 P2 迁移,@contract-pending 列表 + 真实单查)──
// GetTextbook($id):按 id 单查,任意 id 都返回 mockTextbookDetaildev 兜底)
case "GetTextbook": {
const tbId = (variables?.id as string | undefined) ?? "";
const found = mockTextbooks.find((tb) => tb.id === tbId);
// 真实 schema 单查返回 Textbook 字段(不含 chapters
return {
data: {
textbook: found
? {
id: found.id,
title: found.title,
subjectId: found.subjectId,
gradeId: found.gradeId,
version: found.version,
status: found.status,
tenantId: found.tenantId,
createdAt: found.createdAt,
updatedAt: found.updatedAt,
}
: { ...mockTextbookDetail, id: tbId },
},
};
}
// GetTextbooks($subjectId, $gradeId, $q):列表查询
case "GetTextbooks": {
const subjectId = variables?.subjectId as string | undefined;
const gradeId = variables?.gradeId as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockTextbooks];
if (subjectId)
filtered = filtered.filter((tb) => tb.subjectId === subjectId);
if (gradeId) filtered = filtered.filter((tb) => tb.gradeId === gradeId);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter((tb) => tb.title.toLowerCase().includes(ql));
}
return {
data: {
textbooks: { items: filtered, total: filtered.length },
},
};
}
// GetTextbookChapters($textbookId):章节列表(@contract-pending 全 MSW
case "GetTextbookChapters": {
const textbookId = (variables?.textbookId as string) ?? "";
const items = mockTextbookChapters[textbookId] ?? [];
return {
data: {
textbookChapters: { items, total: items.length },
},
};
}
// CreateTextbook($input)mutation 兜底
case "CreateTextbook": {
const newId = `tb-${Date.now()}`;
return { data: { createTextbook: { id: newId } } };
}
// UpdateTextbook($id, $input)mutation 兜底
case "UpdateTextbook": {
const id = (variables?.id as string) ?? "";
return { data: { updateTextbook: { id } } };
}
// ── Legacy widget 兜底P2 迁移后重命名,保留旧 widget 契约)──
// GetQuestionBank($bankId, $type, $limit)question-bank widget 兜底
case "GetQuestionBank": {
const type = (variables?.type as string | undefined) ?? "";
const limit = (variables?.limit as number | undefined) ?? 50;
const legacyQuestions = [
{
id: "qb-001",
type: "single_choice",
difficulty: "easy",
content: "下列哪个是质数?",
options: ["2", "4", "6", "8"],
answer: "A",
tags: ["数论", "质数"],
},
{
id: "qb-002",
type: "multiple_choice",
difficulty: "medium",
content: "下列哪些是偶数?",
options: ["1", "2", "3", "4"],
answer: "B,D",
tags: ["数论", "偶数"],
},
{
id: "qb-003",
type: "fill_blank",
difficulty: "easy",
content: "2 + 2 = ?",
options: [],
answer: "4",
tags: ["加法"],
},
];
let items = legacyQuestions;
if (type) {
items = items.filter((q) => q.type === type);
}
items = items.slice(0, limit);
return { data: { questions: items } };
}
// GetLegacyTextbooks($subjectId, $grade)textbook-manager widget 兜底
case "GetLegacyTextbooks": {
const subjectId = (variables?.subjectId as string | undefined) ?? "";
const grade = (variables?.grade as string | undefined) ?? "";
const legacyTextbooks = [
{
id: "ltb-001",
title: "高中数学必修一legacy",
author: "人教社",
publisher: "人民教育出版社",
isbn: "978-7-107-000000-1",
chapters: [
{ id: "lch-001", title: "第一章 集合与函数" },
{ id: "lch-002", title: "第二章 基本初等函数" },
],
},
{
id: "ltb-002",
title: "高中物理必修一legacy",
author: "张三",
publisher: "教育科学出版社",
isbn: "978-7-5041-000000-2",
chapters: [
{ id: "lch-003", title: "第一章 运动的描述" },
{ id: "lch-004", title: "第二章 匀变速直线运动" },
],
},
];
let items = legacyTextbooks;
if (subjectId) {
items = items.filter((t) => t.id.includes(subjectId));
}
if (grade) {
items = items.filter((t) => t.title.includes(grade));
}
return { data: { textbooks: items } };
}
case "GetLessonPlans":
return {
data: {

View File

@@ -126,6 +126,10 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["TEXTBOOK_READ"],
},
"/shell/teacher/questions": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE", "QUESTION_UPDATE"],
},
"/shell/teacher/scheduling-rules": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
@@ -232,6 +236,22 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
],
},
},
// 教材管理P2 迁移,含详情页 /shell/teacher/textbooks/[id]
{
prefix: "/shell/teacher/textbooks/",
config: {
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["TEXTBOOK_READ"],
},
},
// 题库管理P2 迁移,含子路由 /shell/teacher/questions/[id] 预留)
{
prefix: "/shell/teacher/questions/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE", "QUESTION_UPDATE"],
},
},
// 成绩录入
{
prefix: "/shell/teacher/grades/",

View File

@@ -11,7 +11,7 @@
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useQuestionBank, type Question } from "@/lib/api";
import { useQuestionBank, type QuestionBankItem } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
@@ -53,7 +53,7 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
const [newQuestion, setNewQuestion] = useState<NewQuestion>(
createEmptyNewQuestion,
);
const [localQuestions, setLocalQuestions] = useState<Question[]>([]);
const [localQuestions, setLocalQuestions] = useState<QuestionBankItem[]>([]);
const { data, loading } = useQuestionBank(bankId, {
type: typeFilter || undefined,
@@ -83,7 +83,7 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
if (!newQuestion.content.trim()) {
return;
}
const created: Question = {
const created: QuestionBankItem = {
id: `local-${Date.now()}`,
type: newQuestion.type,
difficulty: newQuestion.difficulty,

View File

@@ -9,7 +9,7 @@
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useState } from "react";
import { useTextbooks } from "@/lib/api";
import { useLegacyTextbooks } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
@@ -22,7 +22,7 @@ export default function TextbookManager(
const [appliedGrade, setAppliedGrade] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, loading } = useTextbooks({
const { data, loading } = useLegacyTextbooks({
subjectId: appliedSubject || undefined,
grade: appliedGrade || undefined,
});