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

@@ -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 };
}