feat(portal-shell): exams 三子页面迁移(analytics/build/edit)

§9.1 教师域 exams 模块补完(继 d066da5 列表/详情/表单后):
- /shell/teacher/exams/[id]/analytics:详情页(图表)- 混合契约
  · 基础统计  assignmentAnalysis(data-ana 子图,schema 已就绪)
  · 扩展字段(排名/每题正确率) MSW 兜底(@contract-pending)
  · 含 Summary/Distribution/QuestionAccuracy/Rankings 四区
- /shell/teacher/exams/[id]/build:工作台页(组卷)@contract-pending
  · 三栏:题库候选 / 已选题目 / 预览
  · 支持搜索/类型/难度筛选,添加/移除/上移/下移/改分
- /shell/teacher/exams/[id]/edit:工作台页(富文本试卷)@contract-pending
  · contentEditable + 工具栏(B/I/U/H1-H3/列表)
  · 右栏试卷属性面板

§11.3 DoD 11 项验收:
1. route-permissions:PREFIX 表 /shell/teacher/exams/ 已覆盖
2. 页面模板:analytics 用 DetailPageShell;build/edit 用 WorkbenchPageShell
3. 三态:loading/error/empty 均实现(workbench 用 errorNode 合并 empty)
4. lib/api hooks:useExamAnalytics/useExamBuild/useQuestionsLibrary/
   useSaveExamBuild/useExamRichEditor/useSaveExamRichContent 6 个
5. @contract-pending MSW 模式:graphql-data.ts 扩展 6 个 case
6. i18n:analytics(19 keys)+build(28 keys)+edit(13 keys) 中英对齐
7. lint:0 errors(4 warnings 在 __generated__)
8. lint:tokens:0 errors
9. notify:success/error/warning 走 @/shared/lib/notify(非 sonner 直引)
10. vitest:transformations 新增 10 函数 22 测试,全量 273/273 通过
11. typecheck:0 errors(noUncheckedIndexedAccess 安全 swap 写法)

剩余:proctoring 标注"二期 WS"按 §9.1 暂缓。
This commit is contained in:
SpecialX
2026-07-22 17:35:54 +08:00
parent d066da563f
commit dca25fc42f
14 changed files with 2467 additions and 0 deletions

View File

@@ -20,8 +20,14 @@ import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
CREATE_EXAM_DOC,
GET_EXAM_ANALYTICS_DOC,
GET_EXAM_BUILD_DOC,
GET_EXAM_DOC,
GET_EXAMS_DOC,
GET_EXAM_RICH_EDITOR_DOC,
GET_QUESTIONS_LIBRARY_DOC,
SAVE_EXAM_BUILD_DOC,
SAVE_EXAM_RICH_CONTENT_DOC,
} from "./operations/exams.graphql";
import type { UseQueryResult } from "./types";
@@ -94,6 +100,154 @@ interface CreateExamResponse {
createExam: { id: string } | null;
}
// ===== Analytics 类型(混合契约:基础统计真实 + 扩展 MSW=====
/** 考试分析汇总 */
export interface ExamAnalyticsSummary {
expectedCount: number;
attendedCount: number;
avgScore: number;
maxScore: number;
minScore: number;
passRate: number;
}
/** 分数段分布 */
export interface ScoreDistribution {
label: string;
count: number;
}
/** 每题正确率 */
export interface ExamQuestionAccuracy {
questionId: string;
order: number;
questionTitle: string;
correctRate: number;
avgScore: number;
maxScore: number;
}
/** 学生排名 */
export interface ExamRanking {
studentId: string;
studentNo: string;
studentName: string;
totalScore: number;
rank: number;
level: string;
}
/** 考试分析完整数据(@contract-pending 扩展字段) */
export interface ExamAnalytics {
examId: string;
examTitle: string;
summary: ExamAnalyticsSummary;
distribution: ScoreDistribution[];
questionAccuracy: ExamQuestionAccuracy[];
rankings: ExamRanking[];
}
/** Analytics 查询响应 */
interface ExamAnalyticsResponse {
examAnalytics: ExamAnalytics | null;
}
// ===== Build 类型(@contract-pending 全 MSW=====
/** 组卷已选题目节点 */
export interface ExamBuildNode {
questionId: string;
score: number;
sortOrder: number;
content: string;
type: string;
difficulty: string;
}
/** 组卷数据 */
export interface ExamBuild {
examId: string;
title: string;
totalScore: number;
passScore: number;
duration: number;
selected: ExamBuildNode[];
}
/** Build 查询响应 */
interface ExamBuildResponse {
examBuild: ExamBuild | null;
}
/** 题库候选题目 */
export interface QuestionItem {
questionId: string;
content: string;
type: string;
difficulty: string;
score: number;
textbookName: string | null;
}
/** 题库筛选条件 */
export interface QuestionsLibraryFilter {
q?: string | null;
type?: string | null;
difficulty?: string | null;
textbook?: string | null;
page?: number;
pageSize?: number;
}
/** 题库列表响应 */
interface QuestionsLibraryResponse {
questionsLibrary: { items: QuestionItem[]; total: number };
}
/** 保存组卷输入 */
export interface SaveExamBuildInput {
examId: string;
questions: Array<{
questionId: string;
score: number;
sortOrder: number;
}>;
}
/** 保存组卷响应 */
interface SaveExamBuildResponse {
saveExamBuild: { examId: string } | null;
}
// ===== Rich Editor 类型(@contract-pending 全 MSW=====
/** 富文本试卷编辑器数据 */
export interface ExamRichEditor {
examId: string;
title: string;
totalScore: number;
questionCount: number;
updatedAt: string;
content: unknown;
}
/** Rich Editor 查询响应 */
interface ExamRichEditorResponse {
examRichEditor: ExamRichEditor | null;
}
/** 保存富文本内容输入 */
export interface SaveExamRichContentInput {
examId: string;
content: unknown;
}
/** 保存富文本内容响应 */
interface SaveExamRichContentResponse {
saveExamRichContent: { examId: string } | null;
}
// ===== 查询选项 =====
export interface ExamQueryOptions {
@@ -206,3 +360,185 @@ export function useCreateExam(): {
return { run, loading, error };
}
// ===== Analytics / Build / Rich Editor Hooks =====
/**
* 查询考试分析数据(混合契约:基础统计真实 + 扩展 MSW
*
* schema 有 assignmentAnalysis 基础字段,但排名/每题正确率等扩展字段走 MSW。
* MSW 开启时返回完整 mock后端补齐扩展字段后切换真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 analytics 页 / §11.4 契约工单
*/
export function useExamAnalytics(
examId: string,
options?: ExamQueryOptions,
): UseQueryResult<ExamAnalytics | null> {
const result = useWidgetQuery<ExamAnalyticsResponse, { examId: string }>(
GET_EXAM_ANALYTICS_DOC,
{ examId },
{
...options,
enabled: options?.enabled ?? examId.length > 0,
},
);
return {
data: result.data?.examAnalytics ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询组卷数据(@contract-pendingMSW 兜底)。
*
* schema 无 examBuild(examId) 根字段,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单
*/
export function useExamBuild(
examId: string,
options?: ExamQueryOptions,
): UseQueryResult<ExamBuild | null> {
const result = useWidgetQuery<ExamBuildResponse, { examId: string }>(
GET_EXAM_BUILD_DOC,
{ examId },
{
...options,
enabled: options?.enabled ?? examId.length > 0,
},
);
return {
data: result.data?.examBuild ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询题库候选列表(@contract-pendingMSW 兜底)。
*
* schema 无 questionsLibrary 根字段,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单
*/
export function useQuestionsLibrary(
filter: QuestionsLibraryFilter,
options?: ExamQueryOptions,
): UseQueryResult<{ items: QuestionItem[]; total: number }> {
const result = useWidgetQuery<
QuestionsLibraryResponse,
{ filter: QuestionsLibraryFilter }
>(
GET_QUESTIONS_LIBRARY_DOC,
{ filter },
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.questionsLibrary,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 保存组卷(@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 build 工作台页 / §11.4 契约工单
*/
export function useSaveExamBuild(): {
run: (input: SaveExamBuildInput) => Promise<{ examId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<SaveExamBuildResponse, { input: SaveExamBuildInput }>(
SAVE_EXAM_BUILD_DOC,
);
const run = async (
input: SaveExamBuildInput,
): Promise<{ examId: string }> => {
const data = await rawRun({ input });
if (!data?.saveExamBuild) {
throw new ApiError("Failed to save exam build", "INTERNAL_ERROR");
}
return data.saveExamBuild;
};
return { run, loading, error };
}
/**
* 查询富文本试卷内容(@contract-pendingMSW 兜底)。
*
* schema 无 examRichEditor(examId) 根字段,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 edit 工作台页 / §11.4 契约工单
*/
export function useExamRichEditor(
examId: string,
options?: ExamQueryOptions,
): UseQueryResult<ExamRichEditor | null> {
const result = useWidgetQuery<ExamRichEditorResponse, { examId: string }>(
GET_EXAM_RICH_EDITOR_DOC,
{ examId },
{
...options,
enabled: options?.enabled ?? examId.length > 0,
},
);
return {
data: result.data?.examRichEditor ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 保存富文本试卷内容(@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 edit 工作台页 / §11.4 契约工单
*/
export function useSaveExamRichContent(): {
run: (input: SaveExamRichContentInput) => Promise<{ examId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
SaveExamRichContentResponse,
{ input: SaveExamRichContentInput }
>(SAVE_EXAM_RICH_CONTENT_DOC);
const run = async (
input: SaveExamRichContentInput,
): Promise<{ examId: string }> => {
const data = await rawRun({ input });
if (!data?.saveExamRichContent) {
throw new ApiError("Failed to save exam rich content", "INTERNAL_ERROR");
}
return data.saveExamRichContent;
};
return { run, loading, error };
}

View File

@@ -70,3 +70,124 @@ export const CREATE_EXAM_DOC = gql`
}
}
`;
// ── 考试分析(混合契约)─────────────────────────────────────────
// 基础统计 ✅ 真实字段 assignmentAnalysisschema 已就绪data-ana 子图)
// 扩展字段(排名/每题正确率/知识点雷达/班级对比/历次趋势)❌ → MSW 兜底
// 契约工单core-edu_contract.md#exam-analytics-extension
export const GET_EXAM_ANALYTICS_DOC = gql`
query GetExamAnalytics($examId: ID!) {
examAnalytics(examId: $examId) {
examId
examTitle
summary {
expectedCount
attendedCount
avgScore
maxScore
minScore
passRate
}
distribution {
label
count
}
questionAccuracy {
questionId
order
questionTitle
correctRate
avgScore
maxScore
}
rankings {
studentId
studentNo
studentName
totalScore
rank
level
}
}
}
`;
// ── 组卷数据(@contract-pending───────────────────────────────
// schema 无 examBuild(examId) 根字段 → MSW 兜底
// 契约工单core-edu_contract.md#exam-build
export const GET_EXAM_BUILD_DOC = gql`
query GetExamBuild($examId: ID!) {
examBuild(examId: $examId) {
examId
title
totalScore
passScore
duration
selected {
questionId
score
sortOrder
content
type
difficulty
}
}
}
`;
// ── 题库候选列表(@contract-pending───────────────────────────
// schema 无 questionsLibrary 根字段 → MSW 兜底
// 契约工单core-edu_contract.md#questions-library
export const GET_QUESTIONS_LIBRARY_DOC = gql`
query GetQuestionsLibrary($filter: QuestionsLibraryFilterInput) {
questionsLibrary(filter: $filter) {
items {
questionId
content
type
difficulty
score
textbookName
}
total
}
}
`;
// ── 保存组卷(@contract-pending mutation──────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 契约工单core-edu_contract.md#save-exam-build
export const SAVE_EXAM_BUILD_DOC = gql`
mutation SaveExamBuild($input: SaveExamBuildInput!) {
saveExamBuild(input: $input) {
examId
}
}
`;
// ── 富文本试卷内容(@contract-pending─────────────────────────
// schema 无 examRichEditor(examId) 根字段 → MSW 兜底
// 契约工单core-edu_contract.md#exam-rich-editor
export const GET_EXAM_RICH_EDITOR_DOC = gql`
query GetExamRichEditor($examId: ID!) {
examRichEditor(examId: $examId) {
examId
title
totalScore
questionCount
updatedAt
content
}
}
`;
// ── 保存富文本内容(@contract-pending mutation────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 契约工单core-edu_contract.md#save-exam-rich-content
export const SAVE_EXAM_RICH_CONTENT_DOC = gql`
mutation SaveExamRichContent($input: SaveExamRichContentInput!) {
saveExamRichContent(input: $input) {
examId
}
}
`;