feat(portal-shell): course-plans + elective 模块 5 页迁移(教师域 §9.1 B2)

§9.1 line 635-636 教师域:
- /shell/teacher/course-plans (列表) + /[id] (详情) — 2 页
- /shell/teacher/elective (列表) + /create (表单) + /[id]/edit (编辑表单) — 3 页
契约:全  schema 无 → MSW 兜底 + @contract-pending

新增文件:
- src/lib/api/{course-plans,elective}.ts (8 hooks 合计)
- src/lib/api/operations/{course-plans,elective}.graphql.ts (8 documents)
- src/features/teacher/{course-plans,elective}/ (clients + transformations + tests)
- src/app/shell/teacher/{course-plans,elective}/ (5 page.tsx + 2 loading + 2 error)

修改文件:
- src/mocks/graphql-data.ts (3 mock 数据 + 8 handler cases)
- src/messages/{zh-CN,en}.json (coursePlans/elective i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 course-plans/elective)
- src/shared/lib/route-permissions.ts (2 EXACT + 2 PREFIX 条目)
- scripts/check-page-count.ts (baseline 48 → 53)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 645 tests passed
- lint:tokens 0 errors
- check:pages 53 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

设计决策:
- COURSE_PLAN_* 权限点不存在于 PERMISSION_BITMAP_ORDER,复用 LESSON_PLAN_READ/CREATE/UPDATE(同备课域语义对齐)
- elective 使用已存在的 ELECTIVE_READ/ELECTIVE_MANAGE
- 编辑表单用 useEffect + initialized state guard 预填数据

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 23:15:45 +08:00
parent 73e09ca29a
commit 80cc1d2461
29 changed files with 3835 additions and 6 deletions

View File

@@ -0,0 +1,280 @@
"use client";
/**
* Course Plans domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域课程计划模块)
*
* 契约状态:全 ❌schema 无 coursePlan(id) / coursePlans 根字段,也无 Mutation 类型)
* → 所有查询与 mutation 走 MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
* 后端补齐后:重跑 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_COURSE_PLAN_DOC,
GET_COURSE_PLAN_DOC,
GET_COURSE_PLANS_DOC,
UPDATE_COURSE_PLAN_DOC,
} from "./operations/course-plans.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pending与 MSW mock 数据形状对齐)=====
/** 课程计划状态枚举 */
export type CoursePlanStatus =
"DRAFT" | "IN_PROGRESS" | "COMPLETED" | "ARCHIVED";
/** 单元状态枚举 */
export type CoursePlanUnitStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
/** 课程计划单元(详情页中的单元项) */
export interface CoursePlanUnit {
id: string;
title: string;
order: number;
lessonCount: number;
completedLessonCount: number;
status: CoursePlanUnitStatus;
}
/**
* 课程计划详情实体(用于详情页)。
*
* schema 无 CoursePlan 类型,字段形状由 MSW mock 定义。
* 后端补齐后对齐真实 schema。
*/
export interface CoursePlanDetail {
id: string;
name: string;
gradeId: string;
subjectId: string;
semester: string;
status: CoursePlanStatus;
description: string;
objectives: string;
units: CoursePlanUnit[];
progress: number;
createdAt: string;
updatedAt: string;
}
/** 课程计划列表项(轻量字段集,用于列表渲染) */
export interface CoursePlanListItem {
id: string;
name: string;
gradeId: string;
subjectId: string;
semester: string;
status: CoursePlanStatus;
description: string;
createdAt: string;
updatedAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface CoursePlansListResponse {
coursePlans: {
items: CoursePlanListItem[];
total: number;
};
}
/** 单查响应(@contract-pendingMSW 返回此结构) */
interface CoursePlanResponse {
coursePlan: CoursePlanDetail | null;
}
/** 创建课程计划输入 */
export interface CreateCoursePlanInput {
name: string;
gradeId: string;
subjectId: string;
semester: string;
description?: string;
objectives?: string;
}
/** 创建课程计划 mutation 响应(@contract-pending */
interface CreateCoursePlanResponse {
createCoursePlan: { id: string } | null;
}
/** 更新课程计划输入 */
export interface UpdateCoursePlanInput {
id: string;
name?: string;
description?: string;
objectives?: string;
status?: CoursePlanStatus;
}
/** 更新课程计划 mutation 响应(@contract-pending */
interface UpdateCoursePlanResponse {
updateCoursePlan: { id: string } | null;
}
// ===== 筛选类型 =====
export interface CoursePlansListFilter {
gradeId?: string;
subjectId?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface CoursePlanQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询课程计划列表(@contract-pendingMSW 兜底)。
*
* schema 无 coursePlans 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useCoursePlans(
filter: CoursePlansListFilter,
options?: CoursePlanQueryOptions,
): UseQueryResult<{ items: CoursePlanListItem[]; total: number }> {
const result = useWidgetQuery<
CoursePlansListResponse,
{
gradeId?: string;
subjectId?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_COURSE_PLANS_DOC,
{
gradeId: filter.gradeId,
subjectId: filter.subjectId,
status: filter.status,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.coursePlans,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询课程计划详情(@contract-pendingMSW 兜底)。
*
* schema 无 coursePlan(id) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/course-plans/[id] 详情页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useCoursePlan(
id: string,
options?: CoursePlanQueryOptions,
): UseQueryResult<CoursePlanDetail | null> {
const result = useWidgetQuery<CoursePlanResponse, { id: string }>(
GET_COURSE_PLAN_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.coursePlan ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建课程计划 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useCreateCoursePlan(): {
run: (input: CreateCoursePlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
CreateCoursePlanResponse,
{ input: CreateCoursePlanInput }
>(CREATE_COURSE_PLAN_DOC);
const run = async (input: CreateCoursePlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createCoursePlan) {
throw new ApiError("Failed to create course plan", "INTERNAL_ERROR");
}
return data.createCoursePlan;
};
return { run, loading, error };
}
/**
* 更新课程计划 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useUpdateCoursePlan(): {
run: (input: UpdateCoursePlanInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
UpdateCoursePlanResponse,
{ input: UpdateCoursePlanInput }
>(UPDATE_COURSE_PLAN_DOC);
const run = async (input: UpdateCoursePlanInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.updateCoursePlan) {
throw new ApiError("Failed to update course plan", "INTERNAL_ERROR");
}
return data.updateCoursePlan;
};
return { run, loading, error };
}

View File

@@ -0,0 +1,255 @@
"use client";
/**
* Elective domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域选修课模块)
*
* 契约状态:全 ❌schema 无 electives / elective(id) 根字段,也无 Mutation 类型)
* → 所有查询与 mutation 走 MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
* 后端补齐后:重跑 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_ELECTIVE_DOC,
GET_ELECTIVE_DOC,
GET_ELECTIVES_DOC,
UPDATE_ELECTIVE_DOC,
} from "./operations/elective.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pending与 MSW mock 数据形状对齐)=====
/** 选修课状态枚举 */
export type ElectiveStatus = "DRAFT" | "OPEN" | "CLOSED" | "ARCHIVED";
/**
* 选修课实体(列表项与单查同构,@contract-pending
*
* schema 无 Elective 类型,字段形状由 MSW mock 定义。
* 后端补齐后对齐真实 schema。
*/
export interface Elective {
id: string;
name: string;
description: string;
capacity: number;
enrolledCount: number;
semester: string;
gradeLevel: string;
subject: string;
teacherId: string;
teacherName: string;
status: ElectiveStatus;
createdAt: string;
updatedAt: string;
}
/** 列表项(与 Elective 同构) */
export type ElectiveListItem = Elective;
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface ElectivesListResponse {
electives: {
items: ElectiveListItem[];
total: number;
};
}
/** 单查响应(@contract-pendingMSW 返回此结构) */
interface ElectiveResponse {
elective: Elective | null;
}
/** 创建选修课输入 */
export interface CreateElectiveInput {
name: string;
description?: string;
capacity: number;
semester: string;
gradeLevel: string;
subject: string;
teacherId?: string;
}
/** 创建选修课 mutation 响应(@contract-pending */
interface CreateElectiveResponse {
createElective: { id: string } | null;
}
/** 更新选修课输入 */
export interface UpdateElectiveInput {
id: string;
name?: string;
description?: string;
capacity?: number;
semester?: string;
gradeLevel?: string;
subject?: string;
teacherId?: string;
status?: ElectiveStatus;
}
/** 更新选修课 mutation 响应(@contract-pending */
interface UpdateElectiveResponse {
updateElective: { id: string } | null;
}
// ===== 筛选类型 =====
export interface ElectivesListFilter {
status?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface ElectiveQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询选修课列表(@contract-pendingMSW 兜底)。
*
* schema 无 electives 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useElectives(
filter: ElectivesListFilter,
options?: ElectiveQueryOptions,
): UseQueryResult<{ items: ElectiveListItem[]; total: number }> {
const result = useWidgetQuery<
ElectivesListResponse,
{
status?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_ELECTIVES_DOC,
{
status: filter.status,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.electives,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询选修课详情(@contract-pendingMSW 兜底)。
*
* schema 无 elective(id) 根字段,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/[id]/edit 编辑表单页预填。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useElective(
id: string,
options?: ElectiveQueryOptions,
): UseQueryResult<Elective | null> {
const result = useWidgetQuery<ElectiveResponse, { id: string }>(
GET_ELECTIVE_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.elective ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建选修课 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/create 表单页。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useCreateElective(): {
run: (input: CreateElectiveInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateElectiveResponse, { input: CreateElectiveInput }>(
CREATE_ELECTIVE_DOC,
);
const run = async (input: CreateElectiveInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createElective) {
throw new ApiError("Failed to create elective", "INTERNAL_ERROR");
}
return data.createElective;
};
return { run, loading, error };
}
/**
* 更新选修课 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 用于 /shell/teacher/elective/[id]/edit 编辑表单页保存。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useUpdateElective(): {
run: (input: UpdateElectiveInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<UpdateElectiveResponse, { input: UpdateElectiveInput }>(
UPDATE_ELECTIVE_DOC,
);
const run = async (input: UpdateElectiveInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.updateElective) {
throw new ApiError("Failed to update elective", "INTERNAL_ERROR");
}
return data.updateElective;
};
return { run, loading, error };
}

View File

@@ -24,5 +24,7 @@ export * from "./attendance";
export * from "./classes";
export * from "./students";
export * from "./student";
export * from "./course-plans";
export * from "./elective";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,100 @@
// Course Plans domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 coursePlans / coursePlan(id) 根字段,也无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#course-plans
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 coursePlans(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#course-plans-list
export const GET_COURSE_PLANS_DOC = gql`
query GetCoursePlans(
$gradeId: ID
$subjectId: ID
$status: String
$q: String
$limit: Int
$offset: Int
) {
coursePlans(
gradeId: $gradeId
subjectId: $subjectId
status: $status
q: $q
limit: $limit
offset: $offset
) {
items {
id
name
gradeId
subjectId
semester
status
description
createdAt
updatedAt
}
total
}
}
`;
// ── 单查(@contract-pending────────────────────────────────────
// schema 无 coursePlan(id) 根字段 → MSW 兜底
// 用于 /shell/teacher/course-plans/[id] 详情页
// 契约工单core-edu_contract.md#course-plan-detail
export const GET_COURSE_PLAN_DOC = gql`
query GetCoursePlan($id: ID!) {
coursePlan(id: $id) {
id
name
gradeId
subjectId
semester
status
description
objectives
units {
id
title
order
lessonCount
completedLessonCount
status
}
progress
createdAt
updatedAt
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建课程计划schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-course-plan
export const CREATE_COURSE_PLAN_DOC = gql`
mutation CreateCoursePlan($input: CreateCoursePlanInput!) {
createCoursePlan(input: $input) {
id
}
}
`;
// ── 更新课程计划 mutation@contract-pending──────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于编辑/状态变更
// 契约工单core-edu_contract.md#update-course-plan
export const UPDATE_COURSE_PLAN_DOC = gql`
mutation UpdateCoursePlan($input: UpdateCoursePlanInput!) {
updateCoursePlan(input: $input) {
id
}
}
`;

View File

@@ -0,0 +1,84 @@
// Elective domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 electives / elective(id) 根字段,也无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md#elective
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 electives(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#electives-list
export const GET_ELECTIVES_DOC = gql`
query GetElectives($status: String, $q: String, $limit: Int, $offset: Int) {
electives(status: $status, q: $q, limit: $limit, offset: $offset) {
items {
id
name
description
capacity
enrolledCount
semester
gradeLevel
subject
teacherId
teacherName
status
createdAt
updatedAt
}
total
}
}
`;
// ── 单查(@contract-pending────────────────────────────────────
// schema 无 elective(id) 根字段 → MSW 兜底
// 用于 /shell/teacher/elective/[id]/edit 编辑表单页预填
// 契约工单core-edu_contract.md#elective-by-id
export const GET_ELECTIVE_DOC = gql`
query GetElective($id: ID!) {
elective(id: $id) {
id
name
description
capacity
enrolledCount
semester
gradeLevel
subject
teacherId
teacherName
status
createdAt
updatedAt
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建选修课schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-elective
export const CREATE_ELECTIVE_DOC = gql`
mutation CreateElective($input: CreateElectiveInput!) {
createElective(input: $input) {
id
}
}
`;
// ── 更新选修课 mutation@contract-pending────────────────────
// schema 无 Mutation 类型 → MSW 兜底
// 用于 /shell/teacher/elective/[id]/edit 编辑表单页保存
// 契约工单core-edu_contract.md#update-elective
export const UPDATE_ELECTIVE_DOC = gql`
mutation UpdateElective($input: UpdateElectiveInput!) {
updateElective(input: $input) {
id
}
}
`;

View File

@@ -14,5 +14,7 @@ export * from "./attendance.graphql";
export * from "./classes.graphql";
export * from "./students.graphql";
export * from "./student.graphql";
export * from "./course-plans.graphql";
export * from "./elective.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";