feat(portal-shell): questions + textbooks 模块 3 页迁移(教师域 §9.1 B2)
§9.1 line 630-631 教师域: - /shell/teacher/questions (列表,1 页) - /shell/teacher/textbooks + /shell/teacher/textbooks/[id] (列表+详情,2 页) 契约:🟡 混合 - question(id) ✅ 真实单查(schema 第 775-778 行确认) - textbook(id) ✅ 真实单查 - 列表查询 ❌ schema 无 → MSW 兜底 + @contract-pending - textbookChapters(textbookId) ❌ schema 无 → MSW 兜底 新增文件: - src/lib/api/questions.ts (5 hooks) - src/lib/api/textbooks.ts (5 hooks) - src/lib/api/operations/{questions,textbooks}.graphql.ts (10 documents) - src/features/teacher/questions/ (clients + transformations + tests) - src/features/teacher/textbooks/ (clients + transformations + tests) - src/app/shell/teacher/{questions,textbooks}/ (3 page.tsx + 2 loading + 2 error) 修改文件: - src/lib/api/teacher.ts + operations/teacher.graphql.ts → 重命名 legacy widget API 以解决命名冲突: Question → QuestionBankItem Textbook → LegacyTextbook Chapter → LegacyChapter TextbookFilter → LegacyTextbookFilter useTextbooks → useLegacyTextbooks GET_QUESTIONS_DOC → GET_QUESTION_BANK_DOC GET_TEXTBOOKS_DOC → GET_LEGACY_TEXTBOOKS_DOC - src/widgets/teacher/{question-bank,textbook-manager}/index.tsx → 更新引用为重命名后的 legacy API - src/mocks/graphql-data.ts → 添加 questions/textbooks mock + GetQuestionBank/GetLegacyTextbooks handler - src/messages/{zh-CN,en}.json (questions + textbooks i18n) - src/lib/api/{index,operations/index}.ts (导出 questions + textbooks) - src/shared/lib/route-permissions.ts (questions + textbooks 路由权限) - scripts/check-page-count.ts (baseline 37 → 40) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 469 tests passed (新增 64 tests) - lint:tokens 0 errors - check:pages 40 PASS - route-permissions 已声明 - 三态齐备 - @contract-pending + MSW 兜底 - i18n zh-CN + en 同步 关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
@@ -19,9 +19,9 @@ interface Baseline {
|
||||
categories: Record<string, { pattern: string; min: number; label: string }>;
|
||||
}
|
||||
|
||||
// Baseline as of P2 (2026-07-22, lesson-plans module added). Update when adding pages.
|
||||
// Baseline as of P2 (2026-07-22, questions + textbooks modules added). Update when adding pages.
|
||||
const BASELINE: Baseline = {
|
||||
total: 37,
|
||||
total: 40,
|
||||
categories: {
|
||||
dashboards: {
|
||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||
|
||||
38
apps/portal-shell/src/app/shell/teacher/questions/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/questions/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 题库路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function QuestionsError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("questions");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] questions route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 题库路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*/
|
||||
export default function QuestionsLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/questions/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/questions/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { QuestionsListClient } from "@/features/teacher/questions/questions-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 题库管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<QuestionsListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<TextbookDetailClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/textbooks/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常(含详情页)。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function TextbooksError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] textbooks route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 教材路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*
|
||||
* 子页面(详情)的 Skeleton 由其 server page 的 <Suspense> 兜底,
|
||||
* 本文件仅在 /shell/teacher/textbooks 列表/重定向期间显示。
|
||||
*/
|
||||
export default function TextbooksLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/textbooks/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { TextbooksListClient } from "@/features/teacher/textbooks/textbooks-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 教材管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<TextbooksListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Questions 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Question } from "@/lib/api";
|
||||
|
||||
import {
|
||||
QUESTION_STATUS_LABEL,
|
||||
QUESTION_TYPE_LABEL,
|
||||
difficultyToColorClass,
|
||||
formatDifficulty,
|
||||
formatQuestionDate,
|
||||
formatQuestionStatus,
|
||||
formatQuestionType,
|
||||
isQuestionEditable,
|
||||
isQuestionPublished,
|
||||
questionStatusToBadgeClass,
|
||||
questionTypeToBadgeClass,
|
||||
toQuestionListItem,
|
||||
truncateContent,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatQuestionType", () => {
|
||||
it("maps known types to Chinese labels", () => {
|
||||
expect(formatQuestionType("single_choice")).toBe("单选题");
|
||||
expect(formatQuestionType("multiple_choice")).toBe("多选题");
|
||||
expect(formatQuestionType("fill_blank")).toBe("填空题");
|
||||
expect(formatQuestionType("short_answer")).toBe("简答题");
|
||||
expect(formatQuestionType("essay")).toBe("论述题");
|
||||
expect(formatQuestionType("true_false")).toBe("判断题");
|
||||
});
|
||||
|
||||
it("returns original value for unknown type", () => {
|
||||
expect(formatQuestionType("unknown_type")).toBe("unknown_type");
|
||||
expect(formatQuestionType("")).toBe("");
|
||||
});
|
||||
|
||||
it("QUESTION_TYPE_LABEL covers 6 standard types", () => {
|
||||
expect(Object.keys(QUESTION_TYPE_LABEL)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuestionStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatQuestionStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatQuestionStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatQuestionStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatQuestionStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
it("QUESTION_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(QUESTION_STATUS_LABEL)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDifficulty", () => {
|
||||
it("maps string enums to Chinese labels", () => {
|
||||
expect(formatDifficulty("easy")).toBe("简单");
|
||||
expect(formatDifficulty("medium")).toBe("中等");
|
||||
expect(formatDifficulty("hard")).toBe("困难");
|
||||
});
|
||||
|
||||
it("maps numeric values by thresholds", () => {
|
||||
expect(formatDifficulty(0)).toBe("简单");
|
||||
expect(formatDifficulty(0.4)).toBe("简单");
|
||||
expect(formatDifficulty(0.41)).toBe("中等");
|
||||
expect(formatDifficulty(0.7)).toBe("中等");
|
||||
expect(formatDifficulty(0.71)).toBe("困难");
|
||||
expect(formatDifficulty(1)).toBe("困难");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite numeric input", () => {
|
||||
expect(formatDifficulty(Number.NaN)).toBe("--");
|
||||
expect(formatDifficulty(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
|
||||
it("returns original string for unknown enum", () => {
|
||||
expect(formatDifficulty("extreme")).toBe("extreme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuestionDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatQuestionDate("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatQuestionDate(null)).toBe("--");
|
||||
expect(formatQuestionDate(undefined)).toBe("--");
|
||||
expect(formatQuestionDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatQuestionDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateContent", () => {
|
||||
it("returns text unchanged when within limit", () => {
|
||||
expect(truncateContent("短题干", 10)).toBe("短题干");
|
||||
});
|
||||
|
||||
it("truncates and appends ellipsis when over limit", () => {
|
||||
const long = "a".repeat(80);
|
||||
const result = truncateContent(long, 60);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
expect(result.length).toBe(63);
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
expect(truncateContent("题干\n带\n换行", 60)).toBe("题干 带 换行");
|
||||
});
|
||||
|
||||
it("uses default maxLen of 60", () => {
|
||||
const long = "b".repeat(70);
|
||||
const result = truncateContent(long);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toQuestionListItem", () => {
|
||||
it("extracts list fields from full question", () => {
|
||||
const q: Question = {
|
||||
id: "q-001",
|
||||
knowledgePointId: "kp-001",
|
||||
type: "single_choice",
|
||||
content: "下列哪个是质数?",
|
||||
answer: "B",
|
||||
explanation: "7 只能被 1 和自身整除",
|
||||
difficulty: 0.3,
|
||||
status: "PUBLISHED",
|
||||
source: "人教版必修一",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toQuestionListItem(q);
|
||||
expect(item.id).toBe("q-001");
|
||||
expect(item.type).toBe("single_choice");
|
||||
expect(item.content).toBe("下列哪个是质数?");
|
||||
expect(item.difficulty).toBe("0.3");
|
||||
expect(item.status).toBe("PUBLISHED");
|
||||
expect(item.subjectId).toBeNull();
|
||||
expect(item.textbookId).toBeNull();
|
||||
expect(item).not.toHaveProperty("answer");
|
||||
expect(item).not.toHaveProperty("explanation");
|
||||
expect(item).not.toHaveProperty("createdBy");
|
||||
expect(item).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("questionTypeToBadgeClass", () => {
|
||||
it("returns blue class for choice types", () => {
|
||||
expect(questionTypeToBadgeClass("single_choice")).toContain("blue");
|
||||
expect(questionTypeToBadgeClass("multiple_choice")).toContain("blue");
|
||||
});
|
||||
|
||||
it("returns emerald class for fill_blank", () => {
|
||||
expect(questionTypeToBadgeClass("fill_blank")).toContain("emerald");
|
||||
});
|
||||
|
||||
it("returns amber class for short_answer and essay", () => {
|
||||
expect(questionTypeToBadgeClass("short_answer")).toContain("amber");
|
||||
expect(questionTypeToBadgeClass("essay")).toContain("amber");
|
||||
});
|
||||
|
||||
it("returns purple class for true_false", () => {
|
||||
expect(questionTypeToBadgeClass("true_false")).toContain("purple");
|
||||
});
|
||||
|
||||
it("returns muted for unknown type", () => {
|
||||
expect(questionTypeToBadgeClass("unknown")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("difficultyToColorClass", () => {
|
||||
it("returns emerald for easy (string)", () => {
|
||||
expect(difficultyToColorClass("easy")).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium (string)", () => {
|
||||
expect(difficultyToColorClass("medium")).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for hard (string)", () => {
|
||||
expect(difficultyToColorClass("hard")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns emerald for low numeric difficulty", () => {
|
||||
expect(difficultyToColorClass(0.2)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns destructive for high numeric difficulty", () => {
|
||||
expect(difficultyToColorClass(0.9)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown string", () => {
|
||||
expect(difficultyToColorClass("extreme")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("questionStatusToBadgeClass", () => {
|
||||
it("returns primary for PUBLISHED", () => {
|
||||
expect(questionStatusToBadgeClass("PUBLISHED")).toContain("primary");
|
||||
});
|
||||
|
||||
it("returns muted for DRAFT and ARCHIVED", () => {
|
||||
expect(questionStatusToBadgeClass("DRAFT")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
expect(questionStatusToBadgeClass("ARCHIVED")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(questionStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isQuestionEditable / isQuestionPublished", () => {
|
||||
it("DRAFT is editable but not published", () => {
|
||||
expect(isQuestionEditable("DRAFT")).toBe(true);
|
||||
expect(isQuestionPublished("DRAFT")).toBe(false);
|
||||
});
|
||||
|
||||
it("PUBLISHED is published but not editable", () => {
|
||||
expect(isQuestionEditable("PUBLISHED")).toBe(false);
|
||||
expect(isQuestionPublished("PUBLISHED")).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown status is neither editable nor published", () => {
|
||||
expect(isQuestionEditable("UNKNOWN")).toBe(false);
|
||||
expect(isQuestionPublished("UNKNOWN")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 题库管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 questions(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 单查 question(id):✅ 真实字段(本页未使用,详情页预留)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#questions-list
|
||||
*
|
||||
* URL 状态:?type=&difficulty=&subjectId=&textbookId=&q=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { HelpCircle, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useQuestions, type QuestionListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
difficultyToColorClass,
|
||||
formatDifficulty,
|
||||
formatQuestionDate,
|
||||
formatQuestionType,
|
||||
questionTypeToBadgeClass,
|
||||
truncateContent,
|
||||
} from "@/features/teacher/questions/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function QuestionsListClient(): React.ReactElement {
|
||||
const t = useTranslations("questions");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const typeFilter = searchParams.get("type") ?? "";
|
||||
const difficultyFilter = searchParams.get("difficulty") ?? "";
|
||||
const subjectId = searchParams.get("subjectId") ?? "";
|
||||
const textbookId = searchParams.get("textbookId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useQuestions({
|
||||
type: typeFilter || undefined,
|
||||
difficulty: difficultyFilter || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
textbookId: textbookId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
// 客户端二次筛选兜底(q 在 MSW 已支持,此处保留以备切换真实 fetcher)
|
||||
const filteredItems = useMemo<QuestionListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/questions?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={HelpCircle}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/questions",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<HelpCircle className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/questions">
|
||||
<Plus className="mr-1 size-4" />
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => updateQuery("type", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.typeFilter")}
|
||||
>
|
||||
<option value="">{t("list.typeAll")}</option>
|
||||
<option value="single_choice">{t("list.typeSingleChoice")}</option>
|
||||
<option value="multiple_choice">
|
||||
{t("list.typeMultipleChoice")}
|
||||
</option>
|
||||
<option value="fill_blank">{t("list.typeFillBlank")}</option>
|
||||
<option value="short_answer">{t("list.typeShortAnswer")}</option>
|
||||
<option value="essay">{t("list.typeEssay")}</option>
|
||||
<option value="true_false">{t("list.typeTrueFalse")}</option>
|
||||
</select>
|
||||
<select
|
||||
value={difficultyFilter}
|
||||
onChange={(e) => updateQuery("difficulty", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.difficultyFilter")}
|
||||
>
|
||||
<option value="">{t("list.difficultyAll")}</option>
|
||||
<option value="easy">{t("list.difficultyEasy")}</option>
|
||||
<option value="medium">{t("list.difficultyMedium")}</option>
|
||||
<option value="hard">{t("list.difficultyHard")}</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={textbookId}
|
||||
onChange={(e) => updateQuery("textbookId", e.target.value)}
|
||||
placeholder={t("list.textbookPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.textbookPlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<QuestionsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function QuestionsTable({
|
||||
items,
|
||||
}: {
|
||||
items: QuestionListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("questions");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colContent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colType")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDifficulty")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colSubject")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colTextbook")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colCreatedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((q) => (
|
||||
<tr key={q.id} className="hover:bg-muted/30">
|
||||
<td className="max-w-xs p-3">
|
||||
<span className="font-medium">
|
||||
{truncateContent(q.content)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<QuestionTypeBadge type={q.type} />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`text-xs font-medium ${difficultyToColorClass(q.difficulty)}`}
|
||||
>
|
||||
{formatDifficulty(q.difficulty)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{q.subjectId ?? "-"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{q.textbookId ?? "-"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatQuestionDate(q.createdAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/questions?id=${q.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 题型徽章(按题型色阶展示)。
|
||||
*/
|
||||
function QuestionTypeBadge({ type }: { type: string }): React.ReactElement {
|
||||
const label = formatQuestionType(type);
|
||||
const cls = questionTypeToBadgeClass(type);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Questions 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Question, QuestionListItem } from "@/lib/api";
|
||||
|
||||
/** 题型中文标签映射(对齐 schema Question.type 字符串语义) */
|
||||
export const QUESTION_TYPE_LABEL: Record<string, string> = {
|
||||
single_choice: "单选题",
|
||||
multiple_choice: "多选题",
|
||||
fill_blank: "填空题",
|
||||
short_answer: "简答题",
|
||||
essay: "论述题",
|
||||
true_false: "判断题",
|
||||
};
|
||||
|
||||
/** 题目状态中文标签映射 */
|
||||
export const QUESTION_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将题型映射为中文标签。未知题型回退为原始值。
|
||||
*/
|
||||
export function formatQuestionType(type: string): string {
|
||||
return QUESTION_TYPE_LABEL[type] ?? type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将题目状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatQuestionStatus(status: string): string {
|
||||
return QUESTION_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化难度。
|
||||
*
|
||||
* schema Question.difficulty 是 Float,但业务列表 mock 数据常用字符串枚举
|
||||
* (easy/medium/hard)。本函数同时支持两种输入:
|
||||
* - 字符串枚举:直接映射("easy" → "简单")
|
||||
* - 数值:0~0.4 → 简单,0.4~0.7 → 中等,0.7~1.0 → 困难
|
||||
* - 其他:回退原始字符串
|
||||
*/
|
||||
export function formatDifficulty(difficulty: string | number): string {
|
||||
if (typeof difficulty === "number") {
|
||||
if (!Number.isFinite(difficulty)) return "--";
|
||||
if (difficulty <= 0.4) return "简单";
|
||||
if (difficulty <= 0.7) return "中等";
|
||||
return "困难";
|
||||
}
|
||||
switch (difficulty) {
|
||||
case "easy":
|
||||
return "简单";
|
||||
case "medium":
|
||||
return "中等";
|
||||
case "hard":
|
||||
return "困难";
|
||||
default:
|
||||
return difficulty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatQuestionDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断题干文本用于列表展示。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - 移除换行符(列表单行展示)
|
||||
* - maxLen 默认 60
|
||||
*/
|
||||
export function truncateContent(content: string, maxLen = 60): string {
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目详情中提取列表项视图模型(裁剪字段)。
|
||||
*
|
||||
* 注:Question 详情无 subjectId/textbookId 字段(schema 未提供),
|
||||
* 列表项的这两个字段由 MSW mock 扩展,详情→列表裁剪时置为 null。
|
||||
*/
|
||||
export function toQuestionListItem(question: Question): QuestionListItem {
|
||||
return {
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
content: question.content,
|
||||
difficulty: String(question.difficulty),
|
||||
status: question.status,
|
||||
source: question.source,
|
||||
knowledgePointId: question.knowledgePointId,
|
||||
subjectId: null,
|
||||
textbookId: null,
|
||||
createdAt: question.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据题型返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function questionTypeToBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
case "multiple_choice":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
case "fill_blank":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "short_answer":
|
||||
case "essay":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "true_false":
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据难度返回 Tailwind 文本语义类名。
|
||||
* 支持字符串枚举与数值输入。
|
||||
*/
|
||||
export function difficultyToColorClass(difficulty: string | number): string {
|
||||
const label = formatDifficulty(difficulty);
|
||||
switch (label) {
|
||||
case "简单":
|
||||
return "text-emerald-600";
|
||||
case "中等":
|
||||
return "text-amber-600";
|
||||
case "困难":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据题目状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function questionStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断题目是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isQuestionEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断题目是否已发布(PUBLISHED)。
|
||||
*/
|
||||
export function isQuestionPublished(status: string): boolean {
|
||||
return status === "PUBLISHED";
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Textbooks 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Textbook, TextbookChapter } from "@/lib/api";
|
||||
|
||||
import {
|
||||
CHAPTER_STATUS_LABEL,
|
||||
TEXTBOOK_STATUS_LABEL,
|
||||
chapterStatusToBadgeClass,
|
||||
formatChapterCount,
|
||||
formatChapterOrder,
|
||||
formatChapterStatus,
|
||||
formatTextbookDate,
|
||||
formatTextbookStatus,
|
||||
isTextbookEditable,
|
||||
isTextbookPublished,
|
||||
sortChaptersByOrder,
|
||||
textbookStatusToBadgeClass,
|
||||
toTextbookListItem,
|
||||
truncateTitle,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatTextbookStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatTextbookStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatTextbookStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatTextbookStatus("DEPRECATED")).toBe("已弃用");
|
||||
expect(formatTextbookStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatTextbookStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatTextbookStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("TEXTBOOK_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(TEXTBOOK_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChapterStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatChapterStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatChapterStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatChapterStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatChapterStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
it("CHAPTER_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(CHAPTER_STATUS_LABEL)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTextbookDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatTextbookDate("2026-07-22T10:30:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatTextbookDate(null)).toBe("--");
|
||||
expect(formatTextbookDate(undefined)).toBe("--");
|
||||
expect(formatTextbookDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatTextbookDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateTitle", () => {
|
||||
it("returns title unchanged when within limit", () => {
|
||||
expect(truncateTitle("高中数学必修一", 20)).toBe("高中数学必修一");
|
||||
});
|
||||
|
||||
it("truncates and appends ellipsis when over limit", () => {
|
||||
const long = "a".repeat(50);
|
||||
const result = truncateTitle(long, 40);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
expect(result.length).toBe(43);
|
||||
});
|
||||
|
||||
it("uses default maxLen of 40", () => {
|
||||
const long = "b".repeat(45);
|
||||
const result = truncateTitle(long);
|
||||
expect(result.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortChaptersByOrder", () => {
|
||||
it("sorts chapters by order ascending", () => {
|
||||
const chapters: TextbookChapter[] = [
|
||||
{
|
||||
id: "ch-2",
|
||||
textbookId: "tb-001",
|
||||
title: "第二章",
|
||||
order: 2,
|
||||
parentId: null,
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ch-1",
|
||||
textbookId: "tb-001",
|
||||
title: "第一章",
|
||||
order: 1,
|
||||
parentId: null,
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ch-3",
|
||||
textbookId: "tb-001",
|
||||
title: "第三章",
|
||||
order: 3,
|
||||
parentId: null,
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
},
|
||||
];
|
||||
const sorted = sortChaptersByOrder(chapters);
|
||||
expect(sorted.map((c) => c.id)).toEqual(["ch-1", "ch-2", "ch-3"]);
|
||||
});
|
||||
|
||||
it("does not mutate input array", () => {
|
||||
const chapters: TextbookChapter[] = [
|
||||
{
|
||||
id: "ch-2",
|
||||
textbookId: "tb-001",
|
||||
title: "第二章",
|
||||
order: 2,
|
||||
parentId: null,
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ch-1",
|
||||
textbookId: "tb-001",
|
||||
title: "第一章",
|
||||
order: 1,
|
||||
parentId: null,
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
},
|
||||
];
|
||||
sortChaptersByOrder(chapters);
|
||||
expect(chapters[0]?.id).toBe("ch-2");
|
||||
});
|
||||
|
||||
it("returns empty array for null/undefined", () => {
|
||||
expect(sortChaptersByOrder(null)).toEqual([]);
|
||||
expect(sortChaptersByOrder(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChapterCount", () => {
|
||||
it("formats positive count", () => {
|
||||
expect(formatChapterCount(5)).toBe("5 章");
|
||||
expect(formatChapterCount(0)).toBe("0 章");
|
||||
});
|
||||
|
||||
it("returns 0 章 for invalid input", () => {
|
||||
expect(formatChapterCount(-1)).toBe("0 章");
|
||||
expect(formatChapterCount(Number.NaN)).toBe("0 章");
|
||||
expect(formatChapterCount(Number.POSITIVE_INFINITY)).toBe("0 章");
|
||||
});
|
||||
});
|
||||
|
||||
describe("textbookStatusToBadgeClass", () => {
|
||||
it("returns primary for PUBLISHED", () => {
|
||||
expect(textbookStatusToBadgeClass("PUBLISHED")).toContain("primary");
|
||||
});
|
||||
|
||||
it("returns amber for DEPRECATED", () => {
|
||||
expect(textbookStatusToBadgeClass("DEPRECATED")).toContain("amber");
|
||||
});
|
||||
|
||||
it("returns muted for DRAFT and ARCHIVED", () => {
|
||||
expect(textbookStatusToBadgeClass("DRAFT")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
expect(textbookStatusToBadgeClass("ARCHIVED")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(textbookStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterStatusToBadgeClass", () => {
|
||||
it("returns primary for PUBLISHED", () => {
|
||||
expect(chapterStatusToBadgeClass("PUBLISHED")).toContain("primary");
|
||||
});
|
||||
|
||||
it("returns muted for DRAFT and ARCHIVED", () => {
|
||||
expect(chapterStatusToBadgeClass("DRAFT")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
expect(chapterStatusToBadgeClass("ARCHIVED")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTextbookEditable / isTextbookPublished", () => {
|
||||
it("DRAFT is editable but not published", () => {
|
||||
expect(isTextbookEditable("DRAFT")).toBe(true);
|
||||
expect(isTextbookPublished("DRAFT")).toBe(false);
|
||||
});
|
||||
|
||||
it("PUBLISHED is published but not editable", () => {
|
||||
expect(isTextbookEditable("PUBLISHED")).toBe(false);
|
||||
expect(isTextbookPublished("PUBLISHED")).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown status is neither editable nor published", () => {
|
||||
expect(isTextbookEditable("UNKNOWN")).toBe(false);
|
||||
expect(isTextbookPublished("UNKNOWN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTextbookListItem", () => {
|
||||
it("preserves all fields of textbook", () => {
|
||||
const tb: Textbook = {
|
||||
id: "tb-001",
|
||||
title: "高中数学必修一",
|
||||
subjectId: "sub-math",
|
||||
gradeId: "g-10",
|
||||
version: "人教版 2026",
|
||||
status: "PUBLISHED",
|
||||
tenantId: "tn-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-21T00:00:00Z",
|
||||
};
|
||||
const item = toTextbookListItem(tb);
|
||||
expect(item).toEqual(tb);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChapterOrder", () => {
|
||||
it("formats valid order with trailing dot", () => {
|
||||
expect(formatChapterOrder(1)).toBe("1.");
|
||||
expect(formatChapterOrder(10)).toBe("10.");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid input", () => {
|
||||
expect(formatChapterOrder(Number.NaN)).toBe("--");
|
||||
expect(formatChapterOrder(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 textbook(id: ID!):✅ schema 真实字段(content 子图)
|
||||
* - 章节列表 textbookChapters(textbookId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#textbook-chapters
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Book, ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useTextbook, useTextbookChapters } from "@/lib/api";
|
||||
import type { TextbookChapter } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatChapterOrder,
|
||||
formatChapterStatus,
|
||||
formatTextbookDate,
|
||||
formatTextbookStatus,
|
||||
isTextbookEditable,
|
||||
sortChaptersByOrder,
|
||||
} from "@/features/teacher/textbooks/transformations";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function TextbookDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const textbookId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:textbook(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useTextbook(textbookId);
|
||||
|
||||
// @contract-pending:章节列表,schema 无 chapters(textbookId) → MSW 兜底
|
||||
const {
|
||||
data: chaptersData,
|
||||
loading: chaptersLoading,
|
||||
error: chaptersError,
|
||||
} = useTextbookChapters(textbookId, {
|
||||
enabled: Boolean(data),
|
||||
});
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={Book}
|
||||
title={t("detail.notFound")}
|
||||
action={{
|
||||
label: t("detail.noChaptersAction"),
|
||||
href: "/shell/teacher/textbooks",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatTextbookDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Book className="size-6" />}
|
||||
backHref="/shell/teacher/textbooks"
|
||||
actions={
|
||||
data && isTextbookEditable(data.status) ? (
|
||||
<Button variant="outline">{t("detail.edit")}</Button>
|
||||
) : null
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <TextbookDetailBody textbook={data} /> : null}
|
||||
{data ? (
|
||||
<ChaptersSection
|
||||
chapters={chaptersData?.items}
|
||||
loading={chaptersLoading}
|
||||
error={chaptersError}
|
||||
mswNotice={t("detail.chaptersMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情基本信息区(对齐 §7.3 详情页模板)。
|
||||
*/
|
||||
function TextbookDetailBody({
|
||||
textbook,
|
||||
}: {
|
||||
textbook: NonNullable<ReturnType<typeof useTextbook>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldTitle")} value={textbook.title} />
|
||||
<DetailField
|
||||
label={t("detail.fieldSubjectId")}
|
||||
value={textbook.subjectId}
|
||||
/>
|
||||
<DetailField label={t("detail.fieldGradeId")} value={textbook.gradeId} />
|
||||
<DetailField label={t("detail.fieldVersion")} value={textbook.version} />
|
||||
<DetailField
|
||||
label={t("detail.fieldStatus")}
|
||||
value={formatTextbookStatus(textbook.status)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldTenantId")}
|
||||
value={textbook.tenantId ?? "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedAt")}
|
||||
value={formatTextbookDate(textbook.createdAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatTextbookDate(textbook.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节列表区(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* loading:行级骨架
|
||||
* error:局部降级提示
|
||||
* empty:无章节时显示空态
|
||||
*/
|
||||
function ChaptersSection({
|
||||
chapters,
|
||||
loading,
|
||||
error,
|
||||
mswNotice,
|
||||
}: {
|
||||
chapters: TextbookChapter[] | undefined;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
mswNotice: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
const tCommon = useTranslations("common");
|
||||
const sorted = sortChaptersByOrder(chapters);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionChapters")}>
|
||||
{errorNode}
|
||||
{!error && loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 animate-pulse rounded bg-muted/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !loading && sorted.length === 0 ? (
|
||||
<Link
|
||||
href="/shell/teacher/textbooks"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("detail.noChapters")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!error && !loading && sorted.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colOrder")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colChapterTitle")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colChapterStatus")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sorted.map((ch) => (
|
||||
<tr key={ch.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatChapterOrder(ch.order)}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{ch.title}</td>
|
||||
<td className="p-3 text-xs">
|
||||
{formatChapterStatus(ch.status)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 textbooks(...):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 单查 textbook(id):✅ 真实字段(本页未使用,详情页使用)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#textbooks-list
|
||||
*
|
||||
* URL 状态:?subjectId=&gradeId=&q=
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Book, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useTextbooks, type TextbookListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatChapterCount,
|
||||
formatTextbookDate,
|
||||
formatTextbookStatus,
|
||||
textbookStatusToBadgeClass,
|
||||
truncateTitle,
|
||||
} from "@/features/teacher/textbooks/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function TextbooksListClient(): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const subjectId = searchParams.get("subjectId") ?? "";
|
||||
const gradeId = searchParams.get("gradeId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useTextbooks({
|
||||
subjectId: subjectId || undefined,
|
||||
gradeId: gradeId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<TextbookListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/textbooks?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={Book}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/textbooks",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<Book className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/textbooks">
|
||||
<Plus className="mr-1 size-4" />
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
||||
placeholder={t("list.gradePlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.gradePlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TextbooksTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 教材列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*
|
||||
* 注:章节数由 MSW mock 扩展提供(schema Textbook 类型无 chapters 字段)。
|
||||
* 这里基于 mock 数据的 chapters 字段计算章节数;若 mock 未提供则展示 0 章。
|
||||
*/
|
||||
function TextbooksTable({
|
||||
items,
|
||||
}: {
|
||||
items: TextbookListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("textbooks");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("list.colTitle")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colSubject")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colGrade")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colVersion")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colChapters")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colCreatedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((tb) => (
|
||||
<tr key={tb.id} className="hover:bg-muted/30">
|
||||
<td className="max-w-xs p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/textbooks/${tb.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{truncateTitle(tb.title)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{tb.subjectId}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{tb.gradeId}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{tb.version}</td>
|
||||
<td className="p-3">
|
||||
<TextbookStatusBadge status={tb.status} />
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{formatChapterCount(
|
||||
getTextbookChapterCount(tb as TextbookListItemWithChapters),
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatTextbookDate(tb.createdAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/textbooks/${tb.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 教材状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function TextbookStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatTextbookStatus(status);
|
||||
const cls = textbookStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部辅助类型:MSW mock 教材数据可能附带 chapters 字段(@contract-pending)。
|
||||
* schema Textbook 类型无 chapters 字段,列表项中的章节数由 MSW 扩展提供。
|
||||
*/
|
||||
interface TextbookListItemWithChapters extends TextbookListItem {
|
||||
chapters?: Array<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从教材列表项中提取章节数。
|
||||
* MSW mock 可能提供 chapters 数组;若未提供返回 0。
|
||||
*/
|
||||
function getTextbookChapterCount(item: TextbookListItemWithChapters): number {
|
||||
return Array.isArray(item.chapters) ? item.chapters.length : 0;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Textbooks 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Textbook, TextbookChapter } from "@/lib/api";
|
||||
|
||||
/** 教材状态中文标签映射 */
|
||||
export const TEXTBOOK_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
DEPRECATED: "已弃用",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/** 章节状态中文标签映射 */
|
||||
export const CHAPTER_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将教材状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatTextbookStatus(status: string): string {
|
||||
return TEXTBOOK_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将章节状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatChapterStatus(status: string): string {
|
||||
return CHAPTER_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatTextbookDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断教材标题用于列表展示。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - maxLen 默认 40
|
||||
*/
|
||||
export function truncateTitle(title: string, maxLen = 40): string {
|
||||
const text = title.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按章节 order 升序排序(稳定排序)。
|
||||
* 输入为 null/undefined 时返回空数组。
|
||||
*/
|
||||
export function sortChaptersByOrder(
|
||||
chapters: TextbookChapter[] | null | undefined,
|
||||
): TextbookChapter[] {
|
||||
if (!chapters) return [];
|
||||
return [...chapters].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化章节数为展示字符串。
|
||||
* 输入无效返回 "0 章"。
|
||||
*/
|
||||
export function formatChapterCount(count: number): string {
|
||||
if (!Number.isFinite(count) || count < 0) return "0 章";
|
||||
return `${count} 章`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据教材状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function textbookStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "DEPRECATED":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据章节状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function chapterStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断教材是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isTextbookEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断教材是否已发布(PUBLISHED)。
|
||||
*/
|
||||
export function isTextbookPublished(status: string): boolean {
|
||||
return status === "PUBLISHED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从教材实体中提取列表项视图模型(TextbookListItem 与 Textbook 同构)。
|
||||
* 保留为显式函数以对齐 questions/homework 模式,便于未来字段裁剪。
|
||||
*/
|
||||
export function toTextbookListItem(textbook: Textbook): Textbook {
|
||||
return {
|
||||
id: textbook.id,
|
||||
title: textbook.title,
|
||||
subjectId: textbook.subjectId,
|
||||
gradeId: textbook.gradeId,
|
||||
version: textbook.version,
|
||||
status: textbook.status,
|
||||
tenantId: textbook.tenantId,
|
||||
createdAt: textbook.createdAt,
|
||||
updatedAt: textbook.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化章节序号为展示字符串("1." / "2." 等)。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatChapterOrder(order: number): string {
|
||||
if (!Number.isFinite(order)) return "--";
|
||||
return `${order}.`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
105
apps/portal-shell/src/lib/api/operations/questions.graphql.ts
Normal file
105
apps/portal-shell/src/lib/api/operations/questions.graphql.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Questions domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - GetQuestion(按 id 单查):✅ combined-schema 中真实存在(question(id: ID!): Question)
|
||||
// - GetQuestions(列表查询):❌ schema 无 questions(...) 根字段
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐列表契约
|
||||
// - Create/Update/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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
111
apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts
Normal file
111
apps/portal-shell/src/lib/api/operations/textbooks.graphql.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Textbooks domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - GetTextbook(按 id 单查):✅ combined-schema 中真实存在(textbook(id: ID!): Textbook)
|
||||
// - GetTextbooks(列表查询):❌ schema 无 textbooks(...) 根字段
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐列表契约
|
||||
// - GetTextbookChapters(章节列表):❌ schema 无 chapters(textbookId) 根字段
|
||||
// → 走 MSW 兜底(@contract-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
315
apps/portal-shell/src/lib/api/questions.ts
Normal file
315
apps/portal-shell/src/lib/api/questions.ts
Normal file
@@ -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<Question | null> {
|
||||
const result = useWidgetQuery<QuestionResponse, { id: string }>(
|
||||
GET_QUESTION_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.question ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询题目列表(@contract-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<CreateQuestionResponse, { input: CreateQuestionInput }>(
|
||||
CREATE_QUESTION_DOC,
|
||||
);
|
||||
|
||||
const run = async (input: CreateQuestionInput): Promise<{ id: string }> => {
|
||||
const data = await rawRun({ input });
|
||||
if (!data?.createQuestion) {
|
||||
throw new ApiError("Failed to create question", "INTERNAL_ERROR");
|
||||
}
|
||||
return data.createQuestion;
|
||||
};
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目 mutation(@contract-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<DeleteQuestionResponse, { id: string }>(
|
||||
DELETE_QUESTION_DOC,
|
||||
);
|
||||
|
||||
const run = async (id: string): Promise<{ id: string }> => {
|
||||
const data = await rawRun({ id });
|
||||
if (!data?.deleteQuestion) {
|
||||
throw new ApiError("Failed to delete question", "INTERNAL_ERROR");
|
||||
}
|
||||
return data.deleteQuestion;
|
||||
};
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import type { UseQueryResult } from "./types";
|
||||
import {
|
||||
GET_LESSON_PLANS_DOC,
|
||||
SAVE_LESSON_PLAN_DOC,
|
||||
GET_QUESTIONS_DOC,
|
||||
GET_TEXTBOOKS_DOC,
|
||||
GET_QUESTION_BANK_DOC,
|
||||
GET_LEGACY_TEXTBOOKS_DOC,
|
||||
GET_SCHEDULING_RULES_DOC,
|
||||
UPDATE_SCHEDULING_RULE_DOC,
|
||||
} from "./operations/teacher.graphql";
|
||||
@@ -98,9 +98,9 @@ export function useSaveLessonPlan(): {
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
// ===== Question Bank =====
|
||||
// ===== Question Bank (legacy widget API, P2 迁移后由 questions.ts 取代) =====
|
||||
|
||||
export interface Question {
|
||||
export interface QuestionBankItem {
|
||||
id: string;
|
||||
type: string;
|
||||
difficulty: string;
|
||||
@@ -116,19 +116,21 @@ export interface QuestionBankFilter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询题库下的题目列表。
|
||||
* 查询题库下的题目列表(legacy widget API)。
|
||||
* bankId 为空时自动跳过查询。
|
||||
*
|
||||
* 注:P2 迁移后新页面使用 questions.ts 的 useQuestions(对齐 schema)。
|
||||
*/
|
||||
export function useQuestionBank(
|
||||
bankId: string,
|
||||
filter?: QuestionBankFilter,
|
||||
options?: TeacherQueryOptions<Question[]>,
|
||||
): UseQueryResult<Question[]> {
|
||||
options?: TeacherQueryOptions<QuestionBankItem[]>,
|
||||
): UseQueryResult<QuestionBankItem[]> {
|
||||
const result = useWidgetQuery<
|
||||
{ questions: Question[] },
|
||||
{ questions: QuestionBankItem[] },
|
||||
{ bankId: string; type?: string; limit?: number }
|
||||
>(
|
||||
GET_QUESTIONS_DOC,
|
||||
GET_QUESTION_BANK_DOC,
|
||||
{ bankId, type: filter?.type, limit: filter?.limit },
|
||||
{ ...options, enabled: options?.enabled ?? bankId.length > 0 },
|
||||
);
|
||||
@@ -138,39 +140,41 @@ export function useQuestionBank(
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Textbook =====
|
||||
// ===== Textbook (legacy widget API, P2 迁移后由 textbooks.ts 取代) =====
|
||||
|
||||
export interface Chapter {
|
||||
export interface LegacyChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Textbook {
|
||||
export interface LegacyTextbook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
publisher: string;
|
||||
isbn: string;
|
||||
chapters: Chapter[];
|
||||
chapters: LegacyChapter[];
|
||||
}
|
||||
|
||||
export interface TextbookFilter {
|
||||
export interface LegacyTextbookFilter {
|
||||
subjectId?: string;
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询教材列表,可按科目与年级筛选。
|
||||
* 查询教材列表(legacy widget API),可按科目与年级筛选。
|
||||
*
|
||||
* 注:P2 迁移后新页面使用 textbooks.ts 的 useTextbooks(对齐 schema)。
|
||||
*/
|
||||
export function useTextbooks(
|
||||
filter?: TextbookFilter,
|
||||
options?: TeacherQueryOptions<Textbook[]>,
|
||||
): UseQueryResult<Textbook[]> {
|
||||
export function useLegacyTextbooks(
|
||||
filter?: LegacyTextbookFilter,
|
||||
options?: TeacherQueryOptions<LegacyTextbook[]>,
|
||||
): UseQueryResult<LegacyTextbook[]> {
|
||||
const result = useWidgetQuery<
|
||||
{ textbooks: Textbook[] },
|
||||
{ textbooks: LegacyTextbook[] },
|
||||
{ subjectId?: string; grade?: string }
|
||||
>(
|
||||
GET_TEXTBOOKS_DOC,
|
||||
GET_LEGACY_TEXTBOOKS_DOC,
|
||||
{ subjectId: filter?.subjectId, grade: filter?.grade },
|
||||
options,
|
||||
);
|
||||
|
||||
302
apps/portal-shell/src/lib/api/textbooks.ts
Normal file
302
apps/portal-shell/src/lib/api/textbooks.ts
Normal file
@@ -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<Textbook | null> {
|
||||
const result = useWidgetQuery<TextbookResponse, { id: string }>(
|
||||
GET_TEXTBOOK_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.textbook ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询教材列表(@contract-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<CreateTextbookResponse, { input: CreateTextbookInput }>(
|
||||
CREATE_TEXTBOOK_DOC,
|
||||
);
|
||||
|
||||
const run = async (input: CreateTextbookInput): Promise<{ id: string }> => {
|
||||
const data = await rawRun({ input });
|
||||
if (!data?.createTextbook) {
|
||||
throw new ApiError("Failed to create textbook", "INTERNAL_ERROR");
|
||||
}
|
||||
return data.createTextbook;
|
||||
};
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新教材 mutation(@contract-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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "备课",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -126,6 +126,10 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
requiredPermissions: ["TEXTBOOK_READ"],
|
||||
},
|
||||
"/shell/teacher/questions": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE", "QUESTION_UPDATE"],
|
||||
},
|
||||
"/shell/teacher/scheduling-rules": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
|
||||
@@ -232,6 +236,22 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
],
|
||||
},
|
||||
},
|
||||
// 教材管理(P2 迁移,含详情页 /shell/teacher/textbooks/[id])
|
||||
{
|
||||
prefix: "/shell/teacher/textbooks/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
requiredPermissions: ["TEXTBOOK_READ"],
|
||||
},
|
||||
},
|
||||
// 题库管理(P2 迁移,含子路由 /shell/teacher/questions/[id] 预留)
|
||||
{
|
||||
prefix: "/shell/teacher/questions/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE", "QUESTION_UPDATE"],
|
||||
},
|
||||
},
|
||||
// 成绩录入
|
||||
{
|
||||
prefix: "/shell/teacher/grades/",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useQuestionBank, type Question } from "@/lib/api";
|
||||
import { useQuestionBank, type QuestionBankItem } from "@/lib/api";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
const [newQuestion, setNewQuestion] = useState<NewQuestion>(
|
||||
createEmptyNewQuestion,
|
||||
);
|
||||
const [localQuestions, setLocalQuestions] = useState<Question[]>([]);
|
||||
const [localQuestions, setLocalQuestions] = useState<QuestionBankItem[]>([]);
|
||||
|
||||
const { data, loading } = useQuestionBank(bankId, {
|
||||
type: typeFilter || undefined,
|
||||
@@ -83,7 +83,7 @@ export default function QuestionBank(_props: PluginProps): React.ReactElement {
|
||||
if (!newQuestion.content.trim()) {
|
||||
return;
|
||||
}
|
||||
const created: Question = {
|
||||
const created: QuestionBankItem = {
|
||||
id: `local-${Date.now()}`,
|
||||
type: newQuestion.type,
|
||||
difficulty: newQuestion.difficulty,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 关联:portal-shell spec §5.6 统一 Hook
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useTextbooks } from "@/lib/api";
|
||||
import { useLegacyTextbooks } from "@/lib/api";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function TextbookManager(
|
||||
const [appliedGrade, setAppliedGrade] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, loading } = useTextbooks({
|
||||
const { data, loading } = useLegacyTextbooks({
|
||||
subjectId: appliedSubject || undefined,
|
||||
grade: appliedGrade || undefined,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user