feat(portal-shell): 教师域考试管理页面迁移(P2)
按 ARCHITECTURE.md §9.1/§10 P2 要求,迁移教师域 exams 模块: - 列表页 /shell/teacher/exams(ListPageShell + URL 状态 + 客户端二次筛选) - 详情页 /shell/teacher/exams/[id](DetailPageShell + 真实 exam(id) 查询) - 新建页 /shell/teacher/exams/new(FormPageShell + MSW 兜底) - 纯函数 transformations.ts + 19 个 vitest 单测 - @contract-pending:exams(classId) 列表查询、createExam mutation 走 MSW - 三态 UI(loading/error/empty)+ 路由级 loading.tsx/error.tsx - i18n:zh-CN/en 双语补全,无硬编码中文 - MSW handlers 支持 variables 透传 §11.3 DoD 验收: - lint: 0 errors(4 个 __generated__ 预存警告) - typecheck: 0 errors - test: 250/250 passed(含 19 个新增 transformations 测试) - lint:tokens: 0 errors
This commit is contained in:
208
apps/portal-shell/src/lib/api/exams.ts
Normal file
208
apps/portal-shell/src/lib/api/exams.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Exams domain API(ARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域考试模块)
|
||||
*
|
||||
* 三类操作:
|
||||
* 1. useExam(按 id 单查):✅ 真实查询 exam(id: ID!),schema 已就绪
|
||||
* 2. useExams(列表查询):❌ schema 无 exams(classId) → MSW 兜底(@contract-pending)
|
||||
* 3. useCreateExam(mutation):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
* 后端补齐后:重跑 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_EXAM_DOC,
|
||||
GET_EXAM_DOC,
|
||||
GET_EXAMS_DOC,
|
||||
} from "./operations/exams.graphql";
|
||||
import type { UseQueryResult } from "./types";
|
||||
|
||||
// ===== 数据类型 =====
|
||||
|
||||
/**
|
||||
* 考试实体(对齐 combined-schema.graphql Exam 类型)
|
||||
*
|
||||
* 字段命名 camelCase(与 schema 一致);Exam.totalScore 在 schema 中是 String,
|
||||
* 但业务层语义为数值,使用时由调用方做 Number() 转换。
|
||||
*/
|
||||
export interface Exam {
|
||||
id: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: string;
|
||||
status: string;
|
||||
statusChangedAt: string;
|
||||
statusChangedBy: string | null;
|
||||
schoolId: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 考试列表项(轻量字段集,用于列表渲染) */
|
||||
export interface ExamListItem {
|
||||
id: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 列表查询响应(@contract-pending 假契约形状,MSW 返回此结构) */
|
||||
interface ExamsListResponse {
|
||||
exams: {
|
||||
items: ExamListItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 单查响应(真实 schema) */
|
||||
interface ExamResponse {
|
||||
exam: Exam | null;
|
||||
}
|
||||
|
||||
/** 新建考试输入 */
|
||||
export interface CreateExamInput {
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
/** 新建考试 mutation 响应(@contract-pending) */
|
||||
interface CreateExamResponse {
|
||||
createExam: { id: string } | null;
|
||||
}
|
||||
|
||||
// ===== 查询选项 =====
|
||||
|
||||
export interface ExamQueryOptions {
|
||||
enabled?: boolean;
|
||||
pollInterval?: number;
|
||||
fetchPolicy?: FetchPolicy;
|
||||
}
|
||||
|
||||
// ===== Hooks =====
|
||||
|
||||
/**
|
||||
* 按 id 查询考试详情(真实 schema,✅ 契约已就绪)。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 详情页
|
||||
*/
|
||||
export function useExam(
|
||||
id: string,
|
||||
options?: ExamQueryOptions,
|
||||
): UseQueryResult<Exam | null> {
|
||||
const result = useWidgetQuery<ExamResponse, { id: string }>(
|
||||
GET_EXAM_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.exam ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级下的考试列表(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 exams(classId) 根字段,由 MSW handlers 返回 mock 数据。
|
||||
* 后端补齐列表查询后切换到真实 fetcher,页面无需改动。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
|
||||
*/
|
||||
export function useExams(
|
||||
classId: string,
|
||||
options?: ExamQueryOptions & {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
): UseQueryResult<{ items: ExamListItem[]; total: number }> {
|
||||
const result = useWidgetQuery<
|
||||
ExamsListResponse,
|
||||
{
|
||||
classId: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
>(
|
||||
GET_EXAMS_DOC,
|
||||
{
|
||||
classId,
|
||||
status: options?.status,
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
},
|
||||
{
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
fetchPolicy: options?.fetchPolicy,
|
||||
pollInterval: options?.pollInterval,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.exams,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建考试(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
|
||||
* 后端补齐 mutation 后切换到真实 fetcher。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
|
||||
*/
|
||||
export function useCreateExam(): {
|
||||
run: (input: CreateExamInput) => Promise<{ id: string }>;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
} {
|
||||
const {
|
||||
run: rawRun,
|
||||
loading,
|
||||
error,
|
||||
} = useWidgetMutation<CreateExamResponse, { input: CreateExamInput }>(
|
||||
CREATE_EXAM_DOC,
|
||||
);
|
||||
|
||||
const run = async (input: CreateExamInput): Promise<{ id: string }> => {
|
||||
const data = await rawRun({ input });
|
||||
if (!data?.createExam) {
|
||||
throw new ApiError("Failed to create exam", "INTERNAL_ERROR");
|
||||
}
|
||||
return data.createExam;
|
||||
};
|
||||
|
||||
return { run, loading, error };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export * from "./dashboard";
|
||||
export * from "./sidebar";
|
||||
export * from "./topbar";
|
||||
export * from "./teacher";
|
||||
export * from "./exams";
|
||||
export * from "./student";
|
||||
export * from "./parent";
|
||||
export * from "./admin";
|
||||
|
||||
72
apps/portal-shell/src/lib/api/operations/exams.graphql.ts
Normal file
72
apps/portal-shell/src/lib/api/operations/exams.graphql.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Exams domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - GetExam(按 id 单查):✅ combined-schema 中真实存在(exam(id: ID!): Exam)
|
||||
// - GetExams(列表查询):❌ schema 无 exams(classId) 根字段
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐列表契约
|
||||
// - CreateExam(mutation):❌ schema 无 Mutation 类型
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐 mutation 契约
|
||||
//
|
||||
// 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
|
||||
// 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
// ── 真实查询:exam(id) 单查 ─────────────────────────────────────
|
||||
// 字段全部对齐 combined-schema.graphql 中 Exam 类型(core-edu 子图)
|
||||
export const GET_EXAM_DOC = gql`
|
||||
query GetExam($id: ID!) {
|
||||
exam(id: $id) {
|
||||
id
|
||||
classId
|
||||
subjectId
|
||||
title
|
||||
description
|
||||
examDate
|
||||
duration
|
||||
totalScore
|
||||
status
|
||||
statusChangedAt
|
||||
statusChangedBy
|
||||
schoolId
|
||||
createdBy
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||
// 列表查询:schema 无 exams(classId) 根字段
|
||||
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
|
||||
// 契约工单:core-edu_contract.md#exams-list
|
||||
export const GET_EXAMS_DOC = gql`
|
||||
query GetExams($classId: ID!, $status: String, $limit: Int, $offset: Int) {
|
||||
exams(classId: $classId, status: $status, limit: $limit, offset: $offset) {
|
||||
items {
|
||||
id
|
||||
classId
|
||||
subjectId
|
||||
title
|
||||
description
|
||||
examDate
|
||||
duration
|
||||
totalScore
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 假契约变更(@contract-pending)─────────────────────────────
|
||||
// 创建考试:schema 无 Mutation 类型
|
||||
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
|
||||
// 契约工单:core-edu_contract.md#create-exam-mutation
|
||||
export const CREATE_EXAM_DOC = gql`
|
||||
mutation CreateExam($input: CreateExamInput!) {
|
||||
createExam(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -4,6 +4,7 @@ export * from "./universal.graphql";
|
||||
export * from "./sidebar.graphql";
|
||||
export * from "./topbar.graphql";
|
||||
export * from "./teacher.graphql";
|
||||
export * from "./exams.graphql";
|
||||
export * from "./student.graphql";
|
||||
export * from "./parent.graphql";
|
||||
export * from "./admin.graphql";
|
||||
|
||||
Reference in New Issue
Block a user