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