Files
Edu/apps/portal-shell/src/lib/api/textbooks.ts
SpecialX 33ebb9a652 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
2026-07-22 20:29:59 +08:00

303 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
}