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:
@@ -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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
|
||||
@@ -70,3 +70,124 @@ export const CREATE_EXAM_DOC = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 考试分析(混合契约)─────────────────────────────────────────
|
||||
// 基础统计 ✅ 真实字段 assignmentAnalysis(schema 已就绪,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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user