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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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