From 33ebb9a652e5cc53fea53ad008afe4ca7a68eea8 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:29:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20questions=20+=20textbooks?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=203=20=E9=A1=B5=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=EF=BC=88=E6=95=99=E5=B8=88=E5=9F=9F=20=C2=A79.1=20B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 --- apps/portal-shell/scripts/check-page-count.ts | 4 +- .../src/app/shell/teacher/questions/error.tsx | 38 ++ .../app/shell/teacher/questions/loading.tsx | 9 + .../src/app/shell/teacher/questions/page.tsx | 23 + .../app/shell/teacher/textbooks/[id]/page.tsx | 24 + .../src/app/shell/teacher/textbooks/error.tsx | 38 ++ .../app/shell/teacher/textbooks/loading.tsx | 12 + .../src/app/shell/teacher/textbooks/page.tsx | 23 + .../__tests__/transformations.test.ts | 249 ++++++++++ .../questions/questions-list-client.tsx | 279 +++++++++++ .../teacher/questions/transformations.ts | 185 ++++++++ .../__tests__/transformations.test.ts | 267 +++++++++++ .../textbooks/textbook-detail-client.tsx | 244 ++++++++++ .../textbooks/textbooks-list-client.tsx | 270 +++++++++++ .../teacher/textbooks/transformations.ts | 160 +++++++ apps/portal-shell/src/lib/api/index.ts | 2 + .../src/lib/api/operations/index.ts | 2 + .../lib/api/operations/questions.graphql.ts | 105 ++++ .../src/lib/api/operations/teacher.graphql.ts | 12 +- .../lib/api/operations/textbooks.graphql.ts | 111 +++++ apps/portal-shell/src/lib/api/questions.ts | 315 ++++++++++++ apps/portal-shell/src/lib/api/teacher.ts | 46 +- apps/portal-shell/src/lib/api/textbooks.ts | 302 ++++++++++++ apps/portal-shell/src/messages/en.json | 93 +++- apps/portal-shell/src/messages/zh-CN.json | 93 +++- apps/portal-shell/src/mocks/graphql-data.ts | 447 +++++++++++++++++- .../src/shared/lib/route-permissions.ts | 20 + .../widgets/teacher/question-bank/index.tsx | 6 +- .../teacher/textbook-manager/index.tsx | 4 +- 29 files changed, 3321 insertions(+), 62 deletions(-) create mode 100644 apps/portal-shell/src/app/shell/teacher/questions/error.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/questions/loading.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/questions/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/textbooks/[id]/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/textbooks/loading.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx create mode 100644 apps/portal-shell/src/features/teacher/questions/__tests__/transformations.test.ts create mode 100644 apps/portal-shell/src/features/teacher/questions/questions-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/questions/transformations.ts create mode 100644 apps/portal-shell/src/features/teacher/textbooks/__tests__/transformations.test.ts create mode 100644 apps/portal-shell/src/features/teacher/textbooks/textbook-detail-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/textbooks/textbooks-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/textbooks/transformations.ts create mode 100644 apps/portal-shell/src/lib/api/operations/questions.graphql.ts create mode 100644 apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts create mode 100644 apps/portal-shell/src/lib/api/questions.ts create mode 100644 apps/portal-shell/src/lib/api/textbooks.ts diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index 8a4630e..6454b17 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,9 +19,9 @@ interface Baseline { categories: Record; } -// 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", diff --git a/apps/portal-shell/src/app/shell/teacher/questions/error.tsx b/apps/portal-shell/src/app/shell/teacher/questions/error.tsx new file mode 100644 index 0000000..f4e321c --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/questions/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/questions/loading.tsx b/apps/portal-shell/src/app/shell/teacher/questions/loading.tsx new file mode 100644 index 0000000..93aa5e5 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/questions/loading.tsx @@ -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 ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/questions/page.tsx b/apps/portal-shell/src/app/shell/teacher/questions/page.tsx new file mode 100644 index 0000000..de30421 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/questions/page.tsx @@ -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 要求)。 + * 业务逻辑在 QuestionsListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/textbooks/[id]/page.tsx b/apps/portal-shell/src/app/shell/teacher/textbooks/[id]/page.tsx new file mode 100644 index 0000000..f5add1a --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/textbooks/[id]/page.tsx @@ -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 边界包裹。 + * 业务逻辑在 TextbookDetailClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx b/apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx new file mode 100644 index 0000000..309fe7a --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/textbooks/loading.tsx b/apps/portal-shell/src/app/shell/teacher/textbooks/loading.tsx new file mode 100644 index 0000000..6b7a7d3 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/textbooks/loading.tsx @@ -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 的 兜底, + * 本文件仅在 /shell/teacher/textbooks 列表/重定向期间显示。 + */ +export default function TextbooksLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx b/apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx new file mode 100644 index 0000000..0551dd7 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx @@ -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 要求)。 + * 业务逻辑在 TextbooksListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/questions/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/questions/__tests__/transformations.test.ts new file mode 100644 index 0000000..69ad434 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/questions/__tests__/transformations.test.ts @@ -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); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/questions/questions-list-client.tsx b/apps/portal-shell/src/features/teacher/questions/questions-list-client.tsx new file mode 100644 index 0000000..1bab49b --- /dev/null +++ b/apps/portal-shell/src/features/teacher/questions/questions-list-client.tsx @@ -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 包裹在 中 + * (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(() => { + 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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = ( + + ); + + return ( + } + actions={ + + } + filters={ + <> + updateQuery("q", v)} + /> + + + 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")} + /> + 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={} + empty={filteredItems.length === 0 && !loading} + emptyNode={emptyNode} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 题目列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function QuestionsTable({ + items, +}: { + items: QuestionListItem[]; +}): React.ReactElement { + const t = useTranslations("questions"); + return ( +
+ + + + + + + + + + + + + + {items.map((q) => ( + + + + + + + + + + ))} + +
+ {t("list.colContent")} + {t("list.colType")} + {t("list.colDifficulty")} + + {t("list.colSubject")} + + {t("list.colTextbook")} + + {t("list.colCreatedAt")} + + {t("list.colActions")} +
+ + {truncateContent(q.content)} + + + + + + {formatDifficulty(q.difficulty)} + + + {q.subjectId ?? "-"} + + {q.textbookId ?? "-"} + + {formatQuestionDate(q.createdAt)} + + + {t("list.viewDetail")} + +
+
+ ); +} + +/** + * 题型徽章(按题型色阶展示)。 + */ +function QuestionTypeBadge({ type }: { type: string }): React.ReactElement { + const label = formatQuestionType(type); + const cls = questionTypeToBadgeClass(type); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/questions/transformations.ts b/apps/portal-shell/src/features/teacher/questions/transformations.ts new file mode 100644 index 0000000..cb5fac1 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/questions/transformations.ts @@ -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 = { + single_choice: "单选题", + multiple_choice: "多选题", + fill_blank: "填空题", + short_answer: "简答题", + essay: "论述题", + true_false: "判断题", +}; + +/** 题目状态中文标签映射 */ +export const QUESTION_STATUS_LABEL: Record = { + 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"; +} diff --git a/apps/portal-shell/src/features/teacher/textbooks/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/textbooks/__tests__/transformations.test.ts new file mode 100644 index 0000000..68f9159 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/textbooks/__tests__/transformations.test.ts @@ -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("--"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/textbooks/textbook-detail-client.tsx b/apps/portal-shell/src/features/teacher/textbooks/textbook-detail-client.tsx new file mode 100644 index 0000000..db5cae9 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/textbooks/textbook-detail-client.tsx @@ -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 包裹在 中。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/textbooks" + actions={ + data && isTextbookEditable(data.status) ? ( + + ) : null + } + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? ( + + ) : null} + + ); +} + +/** + * 详情基本信息区(对齐 §7.3 详情页模板)。 + */ +function TextbookDetailBody({ + textbook, +}: { + textbook: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("textbooks"); + return ( + + + + + + + + + + + ); +} + +/** + * 章节列表区(@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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

{mswNotice}

+
+ ) : null; + + return ( + + {errorNode} + {!error && loading ? ( +
+ {[1, 2, 3].map((i) => ( + + ) : null} + {!error && !loading && sorted.length === 0 ? ( + + + {t("detail.noChapters")} + + ) : null} + {!error && !loading && sorted.length > 0 ? ( +
+ + + + + + + + + + {sorted.map((ch) => ( + + + + + + ))} + +
+ {t("detail.colOrder")} + + {t("detail.colChapterTitle")} + + {t("detail.colChapterStatus")} +
+ {formatChapterOrder(ch.order)} + {ch.title} + {formatChapterStatus(ch.status)} +
+
+ ) : null} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/textbooks/textbooks-list-client.tsx b/apps/portal-shell/src/features/teacher/textbooks/textbooks-list-client.tsx new file mode 100644 index 0000000..a3faad3 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/textbooks/textbooks-list-client.tsx @@ -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 包裹在 中 + * (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(() => { + 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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = ( + + ); + + return ( + } + actions={ + + } + filters={ + <> + updateQuery("q", v)} + /> + 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")} + /> + 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={} + empty={filteredItems.length === 0 && !loading} + emptyNode={emptyNode} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 教材列表表格(纯展示组件,对齐 §8.2 排版规范)。 + * + * 注:章节数由 MSW mock 扩展提供(schema Textbook 类型无 chapters 字段)。 + * 这里基于 mock 数据的 chapters 字段计算章节数;若 mock 未提供则展示 0 章。 + */ +function TextbooksTable({ + items, +}: { + items: TextbookListItem[]; +}): React.ReactElement { + const t = useTranslations("textbooks"); + return ( +
+ + + + + + + + + + + + + + + {items.map((tb) => ( + + + + + + + + + + + ))} + +
{t("list.colTitle")} + {t("list.colSubject")} + {t("list.colGrade")} + {t("list.colVersion")} + {t("list.colStatus")} + {t("list.colChapters")} + + {t("list.colCreatedAt")} + + {t("list.colActions")} +
+ + {truncateTitle(tb.title)} + + + {tb.subjectId} + + {tb.gradeId} + {tb.version} + + + {formatChapterCount( + getTextbookChapterCount(tb as TextbookListItemWithChapters), + )} + + {formatTextbookDate(tb.createdAt)} + + + {t("list.viewDetail")} + +
+
+ ); +} + +/** + * 教材状态徽章(按状态色阶展示)。 + */ +function TextbookStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatTextbookStatus(status); + const cls = textbookStatusToBadgeClass(status); + return ( + + {label} + + ); +} + +/** + * 内部辅助类型: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; +} diff --git a/apps/portal-shell/src/features/teacher/textbooks/transformations.ts b/apps/portal-shell/src/features/teacher/textbooks/transformations.ts new file mode 100644 index 0000000..11b7a3d --- /dev/null +++ b/apps/portal-shell/src/features/teacher/textbooks/transformations.ts @@ -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 = { + DRAFT: "草稿", + PUBLISHED: "已发布", + DEPRECATED: "已弃用", + ARCHIVED: "已归档", +}; + +/** 章节状态中文标签映射 */ +export const CHAPTER_STATUS_LABEL: Record = { + 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}.`; +} diff --git a/apps/portal-shell/src/lib/api/index.ts b/apps/portal-shell/src/lib/api/index.ts index d50caed..2f4c90e 100644 --- a/apps/portal-shell/src/lib/api/index.ts +++ b/apps/portal-shell/src/lib/api/index.ts @@ -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"; diff --git a/apps/portal-shell/src/lib/api/operations/index.ts b/apps/portal-shell/src/lib/api/operations/index.ts index af7b63d..17833c4 100644 --- a/apps/portal-shell/src/lib/api/operations/index.ts +++ b/apps/portal-shell/src/lib/api/operations/index.ts @@ -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"; diff --git a/apps/portal-shell/src/lib/api/operations/questions.graphql.ts b/apps/portal-shell/src/lib/api/operations/questions.graphql.ts new file mode 100644 index 0000000..7fe8272 --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/questions.graphql.ts @@ -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/DeleteQuestion(mutation):❌ 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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/operations/teacher.graphql.ts b/apps/portal-shell/src/lib/api/operations/teacher.graphql.ts index fb4a220..c82c939 100644 --- a/apps/portal-shell/src/lib/api/operations/teacher.graphql.ts +++ b/apps/portal-shell/src/lib/api/operations/teacher.graphql.ts @@ -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 对齐 schema,teacher.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 对齐 schema,teacher.graphql.ts 保留旧 widget 契约) +export const GET_LEGACY_TEXTBOOKS_DOC = gql` + query GetLegacyTextbooks($subjectId: ID, $grade: String) { textbooks(subjectId: $subjectId, grade: $grade) { id title diff --git a/apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts b/apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts new file mode 100644 index 0000000..6947f1d --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts @@ -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-pending);Textbook 类型无 chapters 字段 +// - Create/UpdateTextbook(mutation):❌ 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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/questions.ts b/apps/portal-shell/src/lib/api/questions.ts new file mode 100644 index 0000000..25125db --- /dev/null +++ b/apps/portal-shell/src/lib/api/questions.ts @@ -0,0 +1,315 @@ +"use client"; + +/** + * Questions domain API(ARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域题库模块) + * + * 三类操作: + * 1. useQuestion(按 id 单查):✅ 真实查询 question(id: ID!),schema 已就绪 + * 2. useQuestions(列表查询):❌ schema 无 questions(...) 根字段 → MSW 兜底(@contract-pending) + * 3. useCreate/useUpdate/useDeleteQuestion(mutation):❌ 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 { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * 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( + 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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * 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( + 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 }; +} diff --git a/apps/portal-shell/src/lib/api/teacher.ts b/apps/portal-shell/src/lib/api/teacher.ts index 1e3950f..416dab1 100644 --- a/apps/portal-shell/src/lib/api/teacher.ts +++ b/apps/portal-shell/src/lib/api/teacher.ts @@ -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, -): UseQueryResult { + options?: TeacherQueryOptions, +): UseQueryResult { 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, -): UseQueryResult { +export function useLegacyTextbooks( + filter?: LegacyTextbookFilter, + options?: TeacherQueryOptions, +): UseQueryResult { 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, ); diff --git a/apps/portal-shell/src/lib/api/textbooks.ts b/apps/portal-shell/src/lib/api/textbooks.ts new file mode 100644 index 0000000..2e7a43c --- /dev/null +++ b/apps/portal-shell/src/lib/api/textbooks.ts @@ -0,0 +1,302 @@ +"use client"; + +/** + * Textbooks domain API(ARCHITECTURE.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/useUpdateTextbook(mutation):❌ 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 { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * 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( + 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-pending,MSW 兜底)。 + * + * 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 }; +} diff --git a/apps/portal-shell/src/messages/en.json b/apps/portal-shell/src/messages/en.json index c84110b..c078df0 100644 --- a/apps/portal-shell/src/messages/en.json +++ b/apps/portal-shell/src/messages/en.json @@ -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", diff --git a/apps/portal-shell/src/messages/zh-CN.json b/apps/portal-shell/src/messages/zh-CN.json index 79e67fb..d761ded 100644 --- a/apps/portal-shell/src/messages/zh-CN.json +++ b/apps/portal-shell/src/messages/zh-CN.json @@ -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": "备课", diff --git a/apps/portal-shell/src/mocks/graphql-data.ts b/apps/portal-shell/src/mocks/graphql-data.ts index 6612750..3a6d424 100644 --- a/apps/portal-shell/src/mocks/graphql-data.ts +++ b/apps/portal-shell/src/mocks/graphql-data.ts @@ -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", }, ]; +// mockQuestionDetail:question(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 Textbook):id/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" }, ], }, ]; +// mockTextbookDetail:textbook(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-pending,schema 无 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 都返回 mockQuestionDetail(dev 兜底) + 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 都返回 mockTextbookDetail(dev 兜底) + 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: { diff --git a/apps/portal-shell/src/shared/lib/route-permissions.ts b/apps/portal-shell/src/shared/lib/route-permissions.ts index ace4ae9..362a2b9 100644 --- a/apps/portal-shell/src/shared/lib/route-permissions.ts +++ b/apps/portal-shell/src/shared/lib/route-permissions.ts @@ -126,6 +126,10 @@ export const EXACT_ROUTE_PERMISSIONS: Record = { 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/", diff --git a/apps/portal-shell/src/widgets/teacher/question-bank/index.tsx b/apps/portal-shell/src/widgets/teacher/question-bank/index.tsx index ffaa35d..74f4364 100644 --- a/apps/portal-shell/src/widgets/teacher/question-bank/index.tsx +++ b/apps/portal-shell/src/widgets/teacher/question-bank/index.tsx @@ -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( createEmptyNewQuestion, ); - const [localQuestions, setLocalQuestions] = useState([]); + const [localQuestions, setLocalQuestions] = useState([]); 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, diff --git a/apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx b/apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx index dd80de8..ff5607a 100644 --- a/apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx +++ b/apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx @@ -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(null); - const { data, loading } = useTextbooks({ + const { data, loading } = useLegacyTextbooks({ subjectId: appliedSubject || undefined, grade: appliedGrade || undefined, });