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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user