feat(portal-shell): grades 模块 5 页迁移(教师域 §9.1 B2)

§9.1 B2 grades 行:/grades、/entry、/analytics、/stats、/report-card
契约:grade(id)  真实查询;列表/分析/统计/成绩单 → MSW 兜底

新增文件:
- src/lib/api/grades.ts (6 hooks)
- src/lib/api/operations/grades.graphql.ts (6 documents)
- src/features/teacher/grades/ (7 files: transformations + tests + 5 clients)
- src/app/shell/teacher/grades/ (5 page.tsx + loading.tsx + error.tsx)

修改文件:
- src/mocks/graphql-data.ts (5 mock 常量 + 6 handler cases)
- src/messages/{zh-CN,en}.json (grades i18n)
- src/lib/api/{index,operations/index}.ts (导出 grades)
- scripts/check-page-count.ts (baseline 26 → 31)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 357 tests passed
- lint:tokens 0 errors
- check:pages 31 PASS

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
This commit is contained in:
SpecialX
2026-07-22 18:41:55 +08:00
parent 081cb5fbc3
commit 2f8f3f3855
22 changed files with 3144 additions and 34 deletions

View File

@@ -0,0 +1,426 @@
"use client";
/**
* Grades domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域成绩模块)
*
* 三类操作:
* 1. useGrade按 id 单查):✅ 真实查询 grade(id: ID!)schema 已就绪
* 2. 列表/分析/统计/成绩单查询:❌ schema 无对应字段 → MSW 兜底(@contract-pending
* 3. useCreateGrademutation❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 注homework.ts 已有 useRecordGrade用于作业详情内联批改
* 故本域成绩录入 mutation 命名为 useCreateGrade 以避免命名冲突。
*
* 契约工单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_GRADE_DOC,
GET_GRADE_ANALYTICS_DOC,
GET_GRADE_DOC,
GET_GRADES_LIST_DOC,
GET_GRADE_STATS_DOC,
GET_REPORT_CARD_DOC,
} from "./operations/grades.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 schema Grade 类型)=====
/**
* 成绩实体(对齐 combined-schema.graphql Grade 类型core-edu 子图)
*
* 字段命名 camelCase与 schema 一致score / totalScore 在 schema 中是 String
* 但业务层语义为数值,使用时由调用方做 Number() 转换。
*/
export interface Grade {
id: string;
studentId: string;
examId: string | null;
homeworkId: string | null;
score: string;
totalScore: string;
feedback: string | null;
gradedBy: string;
schoolId: string;
idempotencyKey: string | null;
createdAt: string;
updatedAt: string;
}
/** 成绩列表项(含关联展示字段,用于列表渲染) */
export interface GradeListItem {
id: string;
studentId: string;
studentName: string;
studentNo: string;
classId: string;
className: string;
examId: string | null;
examName: string | null;
homeworkId: string | null;
homeworkTitle: string | null;
score: number;
totalScore: number;
status: string;
gradedAt: string;
gradedBy: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface GradesListResponse {
grades: {
items: GradeListItem[];
total: number;
};
}
/** 单查响应(真实 schema */
interface GradeResponse {
grade: Grade | null;
}
/** 成绩录入输入 */
export interface CreateGradeInput {
studentId: string;
classId: string;
examId?: string;
homeworkId?: string;
score: number;
totalScore: number;
feedback?: string;
}
/** 成绩录入 mutation 响应(@contract-pending */
interface CreateGradeResponse {
createGrade: { gradeId: string } | null;
}
// ===== Analytics 类型(混合契约:基础统计 + 扩展 MSW=====
/** 成绩分析汇总 */
export interface GradeAnalyticsSummary {
totalStudents: number;
gradedCount: number;
avgScore: number;
maxScore: number;
minScore: number;
passRate: number;
passScore: number;
}
/** 分数段分布 */
export interface GradeDistribution {
label: string;
count: number;
}
/** 学生排名 */
export interface GradeRanking {
studentId: string;
studentNo: string;
studentName: string;
score: number;
rank: number;
level: string;
}
/** 成绩分析完整数据(@contract-pending 扩展字段) */
export interface GradeAnalytics {
scope: string;
scopeName: string;
summary: GradeAnalyticsSummary;
distribution: GradeDistribution[];
rankings: GradeRanking[];
}
/** Analytics 查询响应 */
interface GradeAnalyticsResponse {
gradeAnalytics: GradeAnalytics | null;
}
// ===== Stats 类型(@contract-pending 全 MSW=====
/** 班级/学科统计摘要 */
export interface GradeStatsSummary {
classId: string;
className: string;
subjectId: string;
subjectName: string;
studentCount: number;
avgScore: number;
maxScore: number;
minScore: number;
passRate: number;
passCount: number;
failCount: number;
}
/** 成绩统计数据 */
export interface GradeStats {
classId: string;
period: string;
summaries: GradeStatsSummary[];
}
/** Stats 查询响应 */
interface GradeStatsResponse {
gradeStats: GradeStats | null;
}
// ===== Report Card 类型(@contract-pending 全 MSW=====
/** 成绩单条目 */
export interface ReportCardEntry {
subjectId: string;
subjectName: string;
examName: string | null;
homeworkTitle: string | null;
score: number;
totalScore: number;
gradeLevel: string;
feedback: string | null;
gradedAt: string;
}
/** 成绩单完整数据 */
export interface ReportCard {
studentId: string;
studentName: string;
studentNo: string;
className: string;
period: string;
entries: ReportCardEntry[];
totalAvgScore: number;
totalRank: number;
totalStudents: number;
}
/** ReportCard 查询响应 */
interface ReportCardResponse {
reportCard: ReportCard | null;
}
// ===== 查询选项 =====
export interface GradeQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询成绩详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1
*/
export function useGrade(
id: string,
options?: GradeQueryOptions,
): UseQueryResult<Grade | null> {
const result = useWidgetQuery<GradeResponse, { id: string }>(
GET_GRADE_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.grade ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级下的成绩列表(@contract-pendingMSW 兜底)。
*
* schema 无 grades(classId) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useGradesList(
classId: string,
options?: GradeQueryOptions & {
examId?: string;
studentId?: string;
status?: string;
limit?: number;
offset?: number;
},
): UseQueryResult<{ items: GradeListItem[]; total: number }> {
const result = useWidgetQuery<
GradesListResponse,
{
classId: string;
examId?: string;
studentId?: string;
status?: string;
limit?: number;
offset?: number;
}
>(
GET_GRADES_LIST_DOC,
{
classId,
examId: options?.examId,
studentId: options?.studentId,
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?.grades,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 成绩录入 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 注homework.ts 的 useRecordGrade 用于作业详情内联批改input.homeworkId 必填),
* 本 hook 用于成绩模块的独立录入页(支持 examId 或 homeworkId 二选一)。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 录入页 / §11.4 契约工单
*/
export function useCreateGrade(): {
run: (input: CreateGradeInput) => Promise<{ gradeId: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateGradeResponse, { input: CreateGradeInput }>(
CREATE_GRADE_DOC,
);
const run = async (input: CreateGradeInput): Promise<{ gradeId: string }> => {
const data = await rawRun({ input });
if (!data?.createGrade) {
throw new ApiError("Failed to create grade", "INTERNAL_ERROR");
}
return data.createGrade;
};
return { run, loading, error };
}
/**
* 查询成绩分析数据(混合契约:基础统计 + 扩展 MSW
*
* schema 有 assignmentAnalysis 基础字段,但排名/分布等扩展字段走 MSW。
* MSW 开启时返回完整 mock后端补齐扩展字段后切换真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 analytics 页 / §11.4 契约工单
*/
export function useGradeAnalytics(
params: { examId?: string; classId?: string },
options?: GradeQueryOptions,
): UseQueryResult<GradeAnalytics | null> {
const result = useWidgetQuery<
GradeAnalyticsResponse,
{ examId?: string; classId?: string }
>(
GET_GRADE_ANALYTICS_DOC,
{ examId: params.examId, classId: params.classId },
{
...options,
enabled:
options?.enabled ?? (Boolean(params.examId) || Boolean(params.classId)),
},
);
return {
data: result.data?.gradeAnalytics ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级成绩统计(@contract-pendingMSW 兜底)。
*
* schema 无 gradeStats 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/grades/stats 统计页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 统计页 / §11.4 契约工单
*/
export function useGradeStats(
classId: string,
period?: string,
options?: GradeQueryOptions,
): UseQueryResult<GradeStats | null> {
const result = useWidgetQuery<
GradeStatsResponse,
{ classId: string; period?: string }
>(
GET_GRADE_STATS_DOC,
{ classId, period },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.gradeStats ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询学生成绩单(@contract-pendingMSW 兜底)。
*
* schema 无 reportCard 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/grades/report-card 成绩单页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 成绩单页 / §11.4 契约工单
*/
export function useReportCard(
studentId: string,
period?: string,
options?: GradeQueryOptions,
): UseQueryResult<ReportCard | null> {
const result = useWidgetQuery<
ReportCardResponse,
{ studentId: string; period?: string }
>(
GET_REPORT_CARD_DOC,
{ studentId, period },
{
...options,
enabled: options?.enabled ?? studentId.length > 0,
},
);
return {
data: result.data?.reportCard ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -16,6 +16,7 @@ export * from "./topbar";
export * from "./teacher";
export * from "./exams";
export * from "./homework";
export * from "./grades";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,179 @@
// Grades domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetGrade按 id 单查):✅ combined-schema 中真实存在grade(id: ID!): Grade
// - 其余 5 个查询/mutation❌ schema 无对应字段/Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 注homework.graphql.ts 已有同名的 RecordGrade mutation用于作业详情内联批改
// 故本域成绩录入 mutation 命名为 CreateGrade 以避免命名冲突。
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询grade(id) 单查 ─────────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 Grade 类型core-edu 子图)
// 注意score / totalScore 在 schema 中为 String 类型(业务语义为数值)
export const GET_GRADE_DOC = gql`
query GetGrade($id: ID!) {
grade(id: $id) {
id
studentId
examId
homeworkId
score
totalScore
feedback
gradedBy
schoolId
idempotencyKey
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 grades(classId) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#grades-list
export const GET_GRADES_LIST_DOC = gql`
query GetGradesList(
$classId: ID!
$examId: ID
$studentId: ID
$status: String
$limit: Int
$offset: Int
) {
grades(
classId: $classId
examId: $examId
studentId: $studentId
status: $status
limit: $limit
offset: $offset
) {
items {
id
studentId
studentName
studentNo
classId
className
examId
examName
homeworkId
homeworkTitle
score
totalScore
status
gradedAt
gradedBy
}
total
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 成绩录入schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-grade-mutation
export const CREATE_GRADE_DOC = gql`
mutation CreateGrade($input: CreateGradeInput!) {
createGrade(input: $input) {
gradeId
}
}
`;
// ── 成绩分析(混合契约:基础统计部分可用 + 扩展 MSW─────────────
// schema 有 assignmentAnalysis 基础字段,但排名/分布等扩展字段走 MSW
// 契约工单core-edu_contract.md#grade-analytics
export const GET_GRADE_ANALYTICS_DOC = gql`
query GetGradeAnalytics($examId: ID, $classId: ID) {
gradeAnalytics(examId: $examId, classId: $classId) {
scope
scopeName
summary {
totalStudents
gradedCount
avgScore
maxScore
minScore
passRate
passScore
}
distribution {
label
count
}
rankings {
studentId
studentNo
studentName
score
rank
level
}
}
}
`;
// ── 成绩统计(@contract-pending 全 MSW────────────────────────
// schema 无 gradeStats 根字段 → MSW 兜底
// 用于 /shell/teacher/grades/stats 统计页
// 契约工单core-edu_contract.md#grade-stats
export const GET_GRADE_STATS_DOC = gql`
query GetGradeStats($classId: ID!, $period: String) {
gradeStats(classId: $classId, period: $period) {
classId
period
summaries {
classId
className
subjectId
subjectName
studentCount
avgScore
maxScore
minScore
passRate
passCount
failCount
}
}
}
`;
// ── 成绩单(@contract-pending 全 MSW──────────────────────────
// schema 无 reportCard 根字段 → MSW 兜底
// 用于 /shell/teacher/grades/report-card 成绩单页
// 契约工单core-edu_contract.md#report-card
export const GET_REPORT_CARD_DOC = gql`
query GetReportCard($studentId: ID!, $period: String) {
reportCard(studentId: $studentId, period: $period) {
studentId
studentName
studentNo
className
period
entries {
subjectId
subjectName
examName
homeworkTitle
score
totalScore
gradeLevel
feedback
gradedAt
}
totalAvgScore
totalRank
totalStudents
}
}
`;

View File

@@ -6,6 +6,7 @@ export * from "./topbar.graphql";
export * from "./teacher.graphql";
export * from "./exams.graphql";
export * from "./homework.graphql";
export * from "./grades.graphql";
export * from "./student.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";