Преглед изворни кода

feat(schedule): 批次 C 排班页操作面(课程域端点 46~56:排班规则/课表/生成/开下课/打卡/报名/欠费核销)

Q пре 1 час
родитељ
комит
fb84478673
2 измењених фајлова са 1206 додато и 16 уклоњено
  1. 322 5
      src/api/academic/course.ts
  2. 884 11
      src/views/academic/schedule/index.vue

+ 322 - 5
src/api/academic/course.ts

@@ -1,14 +1,24 @@
 /**
- * course.ts —— 课程/排班接口封装(academic 域;端点 44~56)。
+ * course.ts —— 课程/排班接口封装(academic 域;端点 44~56,PC 侧 12 个;端点 57 属移动端不做)。
  *
  * 典型使用(views/academic/schedule/index.vue):
  *   useListPage<CourseDTO> fetcher=listCourses({...scope.toQuery(), keyword, page, pageSize})
  *
- * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
- *   - 金额字符串两位小数;时间 RFC3339;分页 PageData 五字段;status 数字码(前端映射中文)。
+ * 契约对齐(academic-frontend-contract-v1 v1.8/v1.9 §2.4 + §3.C,mock 三方一致):
+ *   - 金额字符串两位小数;时间 RFC3339;分页 PageData 五字段;status 数字码(前端映射中文);
+ *   - 端点 46/48 **无 `room`**(v1.8:源列只有 address,地址≠教室号不得顶替);
+ *   - `week` 为**原值字符串**(v1.8):`"-1"`=全部周 / `"0"`~`"6"`=周日..周六 / `"1,3,5"`=多值;
+ *   - 端点 48 时间窗为**日期闭区间**(`to` 含当日,显式例外,与全局 from 闭/to 开相反);
+ *   - 端点 49 幂等(同 courses+start_time 不重复插);`changed=false` = 本次无新增;
+ *   - 端点 51 重操作:单事务 扣课时+余额变动+分账+课酬,`divide[]`=`{userId,type,amount}`(v1.7 无 level/quota);
+ *   - 端点 52 打卡:逐学生 `type`(1正常/2插班/3赠送)/`pay`/`cost`/`giveCost`(v1.6 C3);
+ *   - 端点 53 欠费字段 = `remainderReceivable`(`unpaid` 是 2018-05 停更僵尸列,禁展示);`realName` 可空(v1.8);
+ *   - 端点 54 报名**不写资金流水、不动余额**(v1.8);重复报名 → **3143**(专用码,非 3140);
+ *   - 分支码:3121(打卡/上课状态非法)/ 3122(排班冲突)/ 3143(重复报名)——读 code 直显 message。
+ *   - 作用域:所有端点 query 带 institutionId/campusId(useScope().toQuery()),越界 → 1004。
  */
 import { http } from '@/api/http';
-import type { PageR } from '@/types/api';
+import type { PageR, R } from '@/types/api';
 
 /** 课程 DTO(端点 44 列表行) */
 export interface CourseDTO {
@@ -20,7 +30,7 @@ export interface CourseDTO {
   name: string;
   /** 课程分类(学科) */
   classify: string;
-  /** 状态数字码(前端映射中文) */
+  /** 状态数字码(0 未开始 / 1 进行中;前端映射中文) */
   status: number;
   /** 授课老师 id 列表 */
   teacherIds: string[];
@@ -50,3 +60,310 @@ export type CourseQuery = {
 export function listCourses(params: CourseQuery): Promise<PageR<CourseDTO>> {
   return http.get<PageR<CourseDTO>>('/api/admin/courses', { params }).then((r) => r.data);
 }
+
+/** 排班规则 DTO(端点 46 列表行;v1.8:无 room) */
+export interface CourseClassDTO {
+  /** 排班规则 id */
+  classId: number;
+  /** 课程 id */
+  courseId: number;
+  /** 授课老师 id(canonical ULID) */
+  teacherId: string;
+  /** 周(原值字符串:"-1"=全部周 / "0"~"6"=周日..周六 / "1,3,5"=多值;v1.8) */
+  week: string;
+  /** 上课起时间(RFC3339) */
+  start: string;
+  /** 下课止时间(RFC3339) */
+  end: string;
+  /** 校区 id(0 = 机构级) */
+  campusId: number;
+}
+
+/** 排班规则分页列表(端点 46,GET /api/admin/courses/:id/classes)。 */
+export function listClasses(courseId: number, params: { page?: number; pageSize?: number }): Promise<PageR<CourseClassDTO>> {
+  return http.get<PageR<CourseClassDTO>>(`/api/admin/courses/${courseId}/classes`, { params }).then((r) => r.data);
+}
+
+/** 新建排班规则入参(端点 47;契约 §2.4:teacher/week/start/end/campus)。⚠ 老师字段名 = `teacher`(契约/mock 口径,非 teacherId)。 */
+export type CreateClassBody = {
+  /** 授课老师 id(canonical ULID;字段名 teacher) */
+  teacher: string;
+  /** 周(原值字符串,同 CourseClassDTO.week) */
+  week: string;
+  /** 上课起时间(RFC3339) */
+  start: string;
+  /** 下课止时间(RFC3339) */
+  end: string;
+  /** 校区 id(0 = 机构级) */
+  campusId: number;
+};
+
+/** 新建排班规则(端点 47,POST /api/admin/courses/:id/classes)。冲突 → 3122(读 code 直显)。 */
+export function createClass(courseId: number, body: CreateClassBody): Promise<R<{ classId: number }>> {
+  return http.post<R<{ classId: number }>>(`/api/admin/courses/${courseId}/classes`, body).then((r) => r.data);
+}
+
+/** 课表实例(排班实例)DTO(端点 48 列表行;v1.8:无 room) */
+export interface CourseRecordDTO {
+  /** 排班实例 id */
+  recordId: number;
+  /** 课程 id */
+  courseId: number;
+  /** 授课老师 id(canonical ULID) */
+  teacherId: string;
+  /** 校区 id(0 = 机构级) */
+  campusId: number;
+  /** 上课起时间(RFC3339) */
+  startTime: string;
+  /** 下课止时间(RFC3339) */
+  endTime: string;
+  /** 状态:0 未开始 / 1 上课中 / 2 已结束(前端映射中文) */
+  status: number;
+  /** 学生数 */
+  studentCount: number;
+}
+
+/** 课表查询参数(端点 48)。⚠ 时间窗为日期闭区间:`to` 含当日(契约 §3.C 显式例外,与全局 from 闭/to 开相反)。 */
+export type RecordQuery = {
+  /** 起始日期(YYYY-MM-DD;闭) */
+  from?: string;
+  /** 截止日期(YYYY-MM-DD;**闭,含当日**) */
+  to?: string;
+  /** 状态筛选(可空) */
+  status?: number;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 课表(排班实例)分页列表(端点 48,GET /api/admin/courses/:id/records)。 */
+export function listRecords(courseId: number, params: RecordQuery): Promise<PageR<CourseRecordDTO>> {
+  return http.get<PageR<CourseRecordDTO>>(`/api/admin/courses/${courseId}/records`, { params }).then((r) => r.data);
+}
+
+/** 生成课表出参(端点 49;幂等:同 courses+start_time 不重复插) */
+export interface GenerateRecordsResult {
+  /** 已生成(存量未开始)课表数 */
+  generated: number;
+  /** 请求生成的天数 */
+  days: number;
+  /** 本次是否有新增(false = 幂等命中,无新增) */
+  changed: boolean;
+}
+
+/** 按 week 生成未来 N 天课表(端点 49,POST /api/admin/courses/:id/records/generate)。 */
+export function generateRecords(courseId: number, body: { days: number }): Promise<R<GenerateRecordsResult>> {
+  return http.post<R<GenerateRecordsResult>>(`/api/admin/courses/${courseId}/records/generate`, body).then((r) => r.data);
+}
+
+/** 开始上课出参(端点 50)。状态非法 → 3121。 */
+export interface StartRecordResult {
+  /** 排班实例 id */
+  recordId: number;
+  /** 新状态(恒 1 上课中) */
+  status: number;
+  /** 课程 id */
+  courseId: number;
+}
+
+/** 开始上课(端点 50,POST /api/admin/courses/records/:recordId/start)。仅未开始(status=0)可开始,否则 3121。 */
+export function startRecord(recordId: number): Promise<R<StartRecordResult>> {
+  return http.post<R<StartRecordResult>>(`/api/admin/courses/records/${recordId}/start`, {}).then((r) => r.data);
+}
+
+/** 分账明细行(端点 51 `divide[]`;v1.7:无 level/quota——分账日志表无层级列,历史值不可回填) */
+export interface DivideItem {
+  /** 分账人 id(canonical ULID) */
+  userId: string;
+  /** 分成类型(1 一次付清 / 2 按课次) */
+  type: number;
+  /** 分成金额(字符串两位小数) */
+  amount: string;
+}
+
+/** 逐学生结算行(端点 51 `perStudent[]`;字段与端点 52 请求同义) */
+export interface PerStudentItem {
+  /** 学生 id(canonical ULID) */
+  userId: string;
+  /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
+  type: number;
+  /** 本次课程费用(字符串两位小数) */
+  pay: string;
+  /** 本次课时费用(字符串两位小数) */
+  cost: string;
+  /** 本次赠送金额(字符串两位小数) */
+  giveCost: string;
+  /** 是否已核销(该学生欠费是否被本次结算核销) */
+  counteracted: boolean;
+}
+
+/** 结束上课(重操作)出参(端点 51:单事务 扣课时+余额变动+分账+课酬) */
+export interface FinishRecordResult {
+  /** 排班实例 id */
+  recordId: number;
+  /** 课程 id */
+  courseId: number;
+  /** 新状态(恒 2 已结束) */
+  status: number;
+  /** 是否有实际变动(false = 幂等/无变动) */
+  changed: boolean;
+  /** 本次总消耗(字符串两位小数) */
+  totalSpend: string;
+  /** 分账明细(v1.7:无 level/quota) */
+  divide: DivideItem[];
+  /** 课酬(detailId=课酬明细 id;reward=本次课酬金额) */
+  payroll: { detailId: number; reward: string };
+  /** 逐学生结算明细 */
+  perStudent: PerStudentItem[];
+}
+
+/**
+ * 结束上课(端点 51,POST /api/admin/courses/records/:recordId/finish)。
+ * ⚠ 重操作(契约 §2.4 明令):一次调用同时产生 扣课时+余额变动+分账+课酬。
+ * 前端纪律:① 二次确认;② loading 锁防重复提交;③ 失败/完成后**刷新整页**(不局部推断)。
+ * 状态非法(未上课/已结束)→ 3121(读 code 直显)。
+ */
+export function finishRecord(recordId: number): Promise<R<FinishRecordResult>> {
+  return http.post<R<FinishRecordResult>>(`/api/admin/courses/records/${recordId}/finish`, {}).then((r) => r.data);
+}
+
+/** 打卡请求逐学生项(端点 52 `list[]`;v1.6 C3:type/pay/cost/giveCost) */
+export interface AttendanceItem {
+  /** 学生 id(canonical ULID) */
+  userId: string;
+  /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
+  type: number;
+  /** 本次课程费用(字符串两位小数;可空 = 不改) */
+  pay?: string;
+  /** 本次课时费用(字符串两位小数;可空 = 不改) */
+  cost?: string;
+  /** 本次赠送金额(字符串两位小数;可空 = 不改) */
+  giveCost?: string;
+}
+
+/** 打卡出参(端点 52) */
+export interface AttendanceResult {
+  /** 排班实例 id */
+  recordId: number;
+  /** 本次打卡学生数 */
+  checked: number;
+}
+
+/**
+ * 打卡(端点 52,POST /api/admin/courses/records/:recordId/attendance)。
+ * 已开课未结束(status=1)才可打卡,否则 3121。
+ */
+export function checkAttendance(recordId: number, body: { list: AttendanceItem[] }): Promise<R<AttendanceResult>> {
+  return http.post<R<AttendanceResult>>(`/api/admin/courses/records/${recordId}/attendance`, body).then((r) => r.data);
+}
+
+/** 报课学生 DTO(端点 53 列表行;v1.8:realName 可空、欠费用 remainderReceivable) */
+export interface CourseStudentDTO {
+  /** 学生 id(canonical ULID) */
+  userId: string;
+  /** 真实姓名(identity 域解析;查不到为 null——区分"没这个人"与"名字为空") */
+  realName: string | null;
+  /** 课程费用(字符串两位小数) */
+  pay: string;
+  /** 课时费用(字符串两位小数) */
+  cost: string;
+  /** 赠送金额(字符串两位小数) */
+  giveCost: string;
+  /** 还需缴费(欠费额;⚠ 不要用 unpaid——2018-05 停更僵尸列) */
+  remainderReceivable: string;
+  /** 状态数字码(前端映射中文) */
+  status: number;
+}
+
+/** 报课学生分页列表(端点 53,GET /api/admin/courses/:id/students)。 */
+export function listCourseStudents(courseId: number, params: { page?: number; pageSize?: number }): Promise<PageR<CourseStudentDTO>> {
+  return http.get<PageR<CourseStudentDTO>>(`/api/admin/courses/${courseId}/students`, { params }).then((r) => r.data);
+}
+
+/** 报名/补报名入参(端点 54;v1.8 补登入参。⚠ 不写资金流水、不动余额——报课行只是应收/实收快照,真实收款走财务端点 3/4) */
+export type EnrollStudentBody = {
+  /** 学生 id(必填) */
+  userId: string;
+  /** 课程费用(必填,金额字符串两位小数) */
+  pay: string;
+  /** 单节课时费用(必填,金额字符串两位小数) */
+  lessonCost: string;
+  /** 课次(必填,>0) */
+  number: number;
+  /** 还需缴费(可空) */
+  remainderReceivable?: string;
+  /** 赠送课次(可空) */
+  giveNumber?: number;
+  /** 现金折扣(可空,金额) */
+  discountCash?: string;
+  /** 赠送金额(可空,金额) */
+  giveCost?: string;
+  /** 排班规则 id(可空) */
+  classId?: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 推荐人 id(可空) */
+  recommenderUserId?: string;
+  /** 备注(可空) */
+  remark?: string;
+};
+
+/** 报名出参(端点 54)。重复报名 → 3143(专用码;不用 3140——那是"参数互相矛盾")。 */
+export interface EnrollResult {
+  /** 是否已报名(true = 成功) */
+  enrolled: boolean;
+  /** 课程 id */
+  courseId: number;
+  /** 学生 id */
+  userId: string;
+}
+
+/** 报名/补报名(端点 54,POST /api/admin/courses/:id/students)。 */
+export function enrollStudent(courseId: number, body: EnrollStudentBody): Promise<R<EnrollResult>> {
+  return http.post<R<EnrollResult>>(`/api/admin/courses/${courseId}/students`, body).then((r) => r.data);
+}
+
+/** 欠费列表行 DTO(端点 55;筛选条件 = remainderReceivable > 0) */
+export interface ArrearsDTO {
+  /** 学生 id(canonical ULID) */
+  studentId: string;
+  /** 课程 id */
+  courseId: number;
+  /** 还需缴费(欠费额) */
+  remainderReceivable: string;
+  /** 是否已核销(判据:该行发生过实收/核销,net_receipts > 0;v1.8 定) */
+  counteracted: boolean;
+}
+
+/** 欠费列表查询参数(端点 55;课程域**全局**——不带 courseId;作用域由 useScope().toQuery() 注入) */
+export type ArrearsQuery = {
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 欠费列表(端点 55,GET /api/admin/courses/arrears;只返回 remainderReceivable > 0 的行)。 */
+export function listArrears(params: ArrearsQuery): Promise<PageR<ArrearsDTO>> {
+  return http.get<PageR<ArrearsDTO>>('/api/admin/courses/arrears', { params }).then((r) => r.data);
+}
+
+/** 欠费核销出参(端点 56;单事务,与 AC-11 CounteractArrears 同 tx) */
+export interface CounteractResult {
+  /** 是否已核销 */
+  counteracted: boolean;
+  /** 学生 id */
+  studentId: string;
+  /** 核销金额(字符串两位小数) */
+  amount: string;
+}
+
+/** 欠费核销(端点 56,POST /api/admin/courses/arrears/counteract)。money 必填 >0。 */
+export function counteractArrears(body: { studentId: string; institutionId: number; campusId: number; money: string }): Promise<R<CounteractResult>> {
+  return http.post<R<CounteractResult>>('/api/admin/courses/arrears/counteract', body).then((r) => r.data);
+}

+ 884 - 11
src/views/academic/schedule/index.vue

@@ -1,30 +1,120 @@
 <script setup lang="ts">
 /**
- * 排班管理页(views/academic/schedule/index.vue)—— 课程/排班列表(菜单「排班管理」,端点 44)。
- * useListPage + ListPage;作用域从 useScope 取。
+ * 排班管理页(views/academic/schedule/index.vue)—— 课程域操作面(菜单「排班管理」,端点 44~56)。
+ *
+ * 结构(自上而下,选中课程后 ②③④⑤ 随动):
+ *   ① 课程列表(端点 44,ListPage;行点击 = 选中课程);
+ *   ② 排班规则区(端点 46 列表 / 47 新建;week 字符串:-1 全周 / 0..6 周日..周六 / 多值逗号);
+ *   ③ 课表区(端点 48 列表,日期闭区间 to 含当日 / 49 生成课表幂等 / 50 开始上课 / 51 结束上课重操作);
+ *   ④ 打卡对话框(端点 52 逐学生 type/pay/cost/giveCost;初值取端点 53 课程级学生);
+ *   ⑤ 报课学生区(端点 53 列表 / 54 报名,不写资金流水、重复 → 3143);
+ *   ⑥ 欠费列表(端点 55 课程域全局 / 56 核销,单事务)。
+ *
+ * 纪律(契约 v1.8/v1.9 §2.4 + §3.C):
+ *   - 端点 51 是**重操作**(单事务:扣课时+余额变动+分账+课酬)→ 二次确认 + loading 锁 + 失败/成功后**刷新整页**(不局部推断);
+ *   - 46/48 无 room(v1.8,源列只有 address);48 时间窗 `to` 含当日(闭区间,显式例外);
+ *   - 49 幂等:changed=false 提示"无新增";
+ *   - 52 已开课未结束才可打卡;50 仅未开始可开始;状态非法 → 3121 读 code 直显;
+ *   - 47 排班冲突 → 3122;54 重复报名 → 3143(专用码,非 3140);
+ *   - 欠费字段一律 `remainderReceivable`(unpaid 是 2018-05 停更僵尸列,禁展示);53 realName 可空(null ≠ 空串);
+ *   - 金额字符串两位小数;时间 RFC3339;作用域只从 useScope() 取。
  */
-import { listCourses, type CourseDTO } from '@/api/academic/course';
+import {
+  listCourses,
+  listClasses,
+  createClass,
+  listRecords,
+  generateRecords,
+  startRecord,
+  finishRecord,
+  checkAttendance,
+  listCourseStudents,
+  enrollStudent,
+  listArrears,
+  counteractArrears,
+  type CourseDTO,
+  type CourseClassDTO,
+  type CourseRecordDTO,
+  type CourseStudentDTO,
+  type ArrearsDTO,
+  type AttendanceItem,
+} from '@/api/academic/course';
+import { listMembers } from '@/api/org/member';
+import type { MemberDTO } from '@/api/org/member';
+import { listCampuses } from '@/api/org/campus';
+import type { CampusDTO } from '@/api/org/campus';
 import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
+import { formatDateTime, formatMoney } from '@/utils/format';
 
 const scope = useScope();
-const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
 
-const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
-  fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+/** 命令式 EP(UMD 全局,http.ts 同款读法) */
+function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
+  const M = (globalThis.ElementPlus ?? {}).ElMessage;
+  if (M) M({ type, message: text });
+  else console.warn(`[${type}]`, text);
+}
+function ElConfirm(text: string): Promise<void> {
+  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
+  if (!B) return Promise.resolve();
+  return B.confirm(text, '操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' })
+    .then(() => undefined)
+    .catch(() => Promise.reject(new Error('cancel')));
+}
+
+/* ================= 选项数据(老师/学生/校区;机构级全量) ================= */
+const staffOpts = ref<MemberDTO[]>([]);
+const studentOpts = ref<MemberDTO[]>([]);
+const campusOpts = ref<CampusDTO[]>([]);
+const teacherName = (id: string): string => staffOpts.value.find((m) => m.userId === id)?.realName ?? id;
+const studentName = (id: string | null): string => (id ? studentOpts.value.find((m) => m.userId === id)?.realName ?? id : '—');
+const campusName = (id: number): string => (id === 0 ? '机构级' : campusOpts.value.find((c) => c.campusId === id)?.campusName ?? String(id));
+onMounted(async () => {
+  const sc = scope.current;
+  const [staff, students, campuses] = await Promise.all([
+    listMembers({ institutionId: sc.institutionId, personType: 'staff', pageSize: 200 }),
+    listMembers({ institutionId: sc.institutionId, personType: 'student', pageSize: 200 }),
+    listCampuses({ institutionId: sc.institutionId, pageSize: 200 }),
+  ]);
+  if (staff.code === 200) staffOpts.value = staff.data?.list ?? [];
+  if (students.code === 200) studentOpts.value = students.data?.list ?? [];
+  if (campuses.code === 200) campusOpts.value = campuses.data?.list ?? [];
 });
 
-const statusText: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已结束' };
+/* ================= 文案映射 ================= */
+const WEEK_NAMES: Record<string, string> = { '0': '周日', '1': '周一', '2': '周二', '3': '周三', '4': '周四', '5': '周五', '6': '周六' };
+/** week 原值字符串 → 中文(v1.8:"-1"=全周 / "0".."6" / "1,3,5" 多值) */
+function weekText(w: string): string {
+  if (w === '-1') return '全周';
+  if (!w) return '-';
+  return w
+    .split(',')
+    .map((x) => WEEK_NAMES[x.trim()] ?? x.trim())
+    .join('、');
+}
+/** 本地当天日期(YYYY-MM-DD;拼 RFC3339 用) */
+function todayStr(): string {
+  const d = new Date();
+  const p = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
+}
+
+/* ================= ① 课程列表(端点 44) ================= */
+const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
+const { rows: courses, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
+  fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+});
+const courseStatusText: Record<number, string> = { 0: '未开始', 1: '进行中' };
 const columns: ListColumn<CourseDTO>[] = [
   { prop: 'id', label: '课程 id', minWidth: 90 },
   { prop: 'name', label: '课程名称', minWidth: 160 },
   { prop: 'classify', label: '学科', minWidth: 100 },
-  { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => statusText[r.status] ?? String(r.status) },
+  { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => courseStatusText[r.status] ?? String(r.status) },
   { prop: 'recordCount', label: '排班数', minWidth: 90 },
 ];
-
 function onSearch(): void {
   page.value = 1;
   void fetchList();
@@ -32,27 +122,753 @@ function onSearch(): void {
 onMounted(() => {
   void fetchList();
 });
+
+/** 选中课程(① 行点击 / ② 区下拉框) */
+const selectedCourseId = ref<number | undefined>(undefined);
+const selectedCourse = computed(() => courses.value.find((c) => c.id === selectedCourseId.value) ?? null);
+function onSelectCourse(id: number): void {
+  selectedCourseId.value = id;
+}
+
+/* ================= ② 排班规则区(端点 46/47) ================= */
+const classRows = ref<CourseClassDTO[]>([]);
+const classLoading = ref(false);
+async function loadClasses(): Promise<void> {
+  if (!selectedCourseId.value) {
+    classRows.value = [];
+    return;
+  }
+  classLoading.value = true;
+  try {
+    const r = await listClasses(selectedCourseId.value, { pageSize: 200 });
+    if (r.code === 200) classRows.value = r.data?.list ?? [];
+    else ElMessage_('error', r.message || '排班规则加载失败');
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    classLoading.value = false;
+  }
+}
+watch(selectedCourseId, () => {
+  void loadClasses();
+  void fetchRecords();
+  void loadStudents();
+});
+
+/** 新建排班规则对话框(端点 47;冲突 → 3122) */
+const classVisible = ref(false);
+const classBusy = ref(false);
+const classForm = reactive({
+  teacher: '',
+  /** 周多选(-1 全周 或 0..6 子集;提交时拼逗号字符串;-1 与具体周互斥) */
+  weeks: [] as number[],
+  start: '',
+  end: '',
+  campusId: 0,
+});
+function openClass(): void {
+  classForm.teacher = staffOpts.value[0]?.userId ?? '';
+  classForm.weeks = [];
+  classForm.start = '';
+  classForm.end = '';
+  classForm.campusId = scope.current.campusId;
+  classVisible.value = true;
+}
+/** weeks 数组 → week 原值字符串(v1.8) */
+function weeksToString(): string {
+  const ws = classForm.weeks;
+  if (ws.length === 0) return '';
+  if (ws.includes(-1)) return '-1';
+  return [...ws].sort((a, b) => a - b).map(String).join(',');
+}
+async function doCreateClass(): Promise<void> {
+  if (!selectedCourseId.value) return;
+  if (!classForm.teacher || !weeksToString() || !classForm.start || !classForm.end) {
+    ElMessage_('warning', '请填全老师/周/上课起止时间');
+    return;
+  }
+  if (Number(classForm.end) < Number(classForm.start)) {
+    ElMessage_('warning', '下课时间需晚于上课时间');
+    return;
+  }
+  classBusy.value = true;
+  try {
+    const r = await createClass(selectedCourseId.value, {
+      teacher: classForm.teacher,
+      week: weeksToString(),
+      // 本地日期 + 时分秒 → RFC3339(无时区后缀 = 本地时间,与后端 dev 环境口径一致)
+      start: `${todayStr()}T${classForm.start}`,
+      end: `${todayStr()}T${classForm.end}`,
+      campusId: classForm.campusId,
+    });
+    if (r.code === 200) {
+      ElMessage_('success', '排班规则已创建');
+      classVisible.value = false;
+      void loadClasses();
+    } else {
+      // 3122 排班冲突 / 2001 参数缺失:读 code 直显
+      ElMessage_('warning', r.message || `创建失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    classBusy.value = false;
+  }
+}
+
+/* ================= ③ 课表区(端点 48/49/50/51) ================= */
+const recFilters = reactive<{ range: [string, string] | []; status: number | undefined }>({ range: [], status: undefined });
+const { rows: records, total: recTotal, page: recPage, pageSize: recPageSize, loading: recLoading, error: recError, fetchList: fetchRecords } =
+  useListPage<CourseRecordDTO>({
+    fetcher: (p, ps) =>
+      listRecords(selectedCourseId.value ?? 0, {
+        // ⚠ 时间窗闭区间:to 含当日(契约 §3.C 显式例外)——daterange 原样传,不做 +1 天
+        from: recFilters.range[0] || undefined,
+        to: recFilters.range[1] || undefined,
+        status: recFilters.status,
+        page: p,
+        pageSize: ps,
+      }),
+  });
+const recStatusText: Record<number, string> = { 0: '未开始', 1: '上课中', 2: '已结束' };
+const recColumns: ListColumn<CourseRecordDTO>[] = [
+  { prop: 'recordId', label: '课表 id', minWidth: 90 },
+  { prop: 'teacherId', label: '老师', minWidth: 120, formatter: (r) => teacherName(r.teacherId) },
+  { prop: 'campusId', label: '校区', minWidth: 110, formatter: (r) => campusName(r.campusId) },
+  { prop: 'startTime', label: '上课时间', minWidth: 150, formatter: (r) => formatDateTime(r.startTime) },
+  { prop: 'endTime', label: '下课时间', minWidth: 150, formatter: (r) => formatDateTime(r.endTime) },
+  { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => recStatusText[r.status] ?? String(r.status) },
+  { prop: 'studentCount', label: '学生数', minWidth: 80 },
+];
+function onRecSearch(): void {
+  recPage.value = 1;
+  void fetchRecords();
+}
+
+/** 生成课表对话框(端点 49;幂等:changed=false 提示无新增) */
+const genVisible = ref(false);
+const genBusy = ref(false);
+const genDays = ref(14);
+function openGenerate(): void {
+  genDays.value = 14;
+  genVisible.value = true;
+}
+async function doGenerate(): Promise<void> {
+  if (!selectedCourseId.value) return;
+  genBusy.value = true;
+  try {
+    const r = await generateRecords(selectedCourseId.value, { days: genDays.value });
+    if (r.code === 200 && r.data) {
+      if (r.data.changed) ElMessage_('success', `课表已更新:当前 ${r.data.generated} 节未开始课表(本次有新增)`);
+      else ElMessage_('warning', `无新增(幂等命中):当前 ${r.data.generated} 节未开始课表,请求 ${r.data.days} 天已覆盖`);
+      genVisible.value = false;
+      recPage.value = 1;
+      void fetchRecords();
+    } else {
+      ElMessage_('warning', r.message || `生成失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    genBusy.value = false;
+  }
+}
+
+/** 开始上课(端点 50;仅未开始可开始,3121 读 code 直显) */
+const startBusy = ref(false);
+async function doStart(rec: CourseRecordDTO): Promise<void> {
+  startBusy.value = true;
+  try {
+    const r = await startRecord(rec.recordId);
+    if (r.code === 200) {
+      ElMessage_('success', '已开始上课');
+      void fetchRecords();
+    } else {
+      // 3121 状态非法:读 code 直显 + 刷新课表(状态可能已被他人变更)
+      ElMessage_('warning', r.message || `开始失败(code ${r.code})`);
+      void fetchRecords();
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    startBusy.value = false;
+  }
+}
+
+/** 结束上课(端点 51 · 重操作:扣课时+余额变动+分账+课酬)。纪律:二次确认 + loading 锁 + 失败/成功后刷新整页。 */
+const finishBusy = ref(false);
+async function doFinish(rec: CourseRecordDTO): Promise<void> {
+  if (finishBusy.value) return;
+  try {
+    await ElConfirm(
+      `确认结束课表 ${rec.recordId}(${formatDateTime(rec.startTime)})?\n本操作为单事务重操作:将同时产生 扣课时 + 余额变动 + 分账明细 + 课酬记录,不可撤销。`,
+    );
+  } catch {
+    return;
+  }
+  finishBusy.value = true;
+  try {
+    const r = await finishRecord(rec.recordId);
+    if (r.code === 200 && r.data) {
+      ElMessage_('success', `下课结算完成:消耗 ${r.data.totalSpend},分账 ${r.data.divide.length} 条,课酬 ${r.data.payroll.reward}`);
+    } else {
+      // 3121 状态非法 / 其它:读 code 直显
+      ElMessage_('warning', r.message || `结束失败(code ${r.code})`);
+    }
+    // 契约明令:失败/成功后均刷新整页(课表 + 报课学生 + 欠费),不局部推断
+    recPage.value = 1;
+    void fetchRecords();
+    void loadStudents();
+    void fetchArrears();
+  } catch {
+    ElMessage_('error', '网络连接异常');
+    recPage.value = 1;
+    void fetchRecords();
+  } finally {
+    finishBusy.value = false;
+  }
+}
+
+/* ================= ④ 打卡对话框(端点 52;初值取端点 53 课程级学生) ================= */
+const attVisible = ref(false);
+const attBusy = ref(false);
+const attRecord = ref<CourseRecordDTO | null>(null);
+interface AttRow extends AttendanceItem {
+  /** 展示名(53 realName 可空 → 兜底 userId) */
+  name: string;
+}
+const attRows = ref<AttRow[]>([]);
+const attTypeText: Record<number, string> = { 1: '正常', 2: '插班', 3: '赠送' };
+async function openAttendance(rec: CourseRecordDTO): Promise<void> {
+  attRecord.value = rec;
+  attRows.value = [];
+  attVisible.value = true;
+  if (!selectedCourseId.value) return;
+  // 初值 = 端点 53 课程级报课学生(risk-1 免后端方案:52 提交时后端按该课实际学生匹配,不在课内的 userId 不生效)
+  const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
+  if (r.code !== 200) {
+    ElMessage_('error', r.message || '学生初值加载失败');
+    return;
+  }
+  attRows.value = (r.data?.list ?? []).map((s) => ({
+    userId: s.userId,
+    name: s.realName ?? s.userId,
+    type: 1,
+    pay: s.pay,
+    cost: s.cost,
+    giveCost: s.giveCost,
+  }));
+}
+async function doAttendance(): Promise<void> {
+  const rec = attRecord.value;
+  if (!rec) return;
+  const list: AttendanceItem[] = attRows.value.map(({ userId, type, pay, cost, giveCost }) => ({
+    userId,
+    type,
+    ...(pay ? { pay } : {}),
+    ...(cost ? { cost } : {}),
+    ...(giveCost ? { giveCost } : {}),
+  }));
+  attBusy.value = true;
+  try {
+    const r = await checkAttendance(rec.recordId, { list });
+    if (r.code === 200 && r.data) {
+      ElMessage_('success', `打卡完成:${r.data.checked} 人`);
+      attVisible.value = false;
+      void fetchRecords();
+    } else {
+      // 3121 已结束不可再打卡:读 code 直显 + 刷新课表
+      ElMessage_('warning', r.message || `打卡失败(code ${r.code})`);
+      void fetchRecords();
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    attBusy.value = false;
+  }
+}
+
+/* ================= ⑤ 报课学生区(端点 53/54) ================= */
+const stuRows = ref<CourseStudentDTO[]>([]);
+const stuLoading = ref(false);
+async function loadStudents(): Promise<void> {
+  if (!selectedCourseId.value) {
+    stuRows.value = [];
+    return;
+  }
+  stuLoading.value = true;
+  try {
+    const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
+    if (r.code === 200) stuRows.value = r.data?.list ?? [];
+    else ElMessage_('error', r.message || '报课学生加载失败');
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    stuLoading.value = false;
+  }
+}
+const stuStatusText: Record<number, string> = { 0: '未确认', 1: '在读', 2: '结课' };
+
+/** 报名对话框(端点 54;不写资金流水、不动余额;重复 → 3143) */
+const enrollVisible = ref(false);
+const enrollBusy = ref(false);
+const enrollForm = reactive({
+  userId: '',
+  pay: '',
+  lessonCost: '',
+  number: 1,
+  giveNumber: '',
+  discountCash: '',
+  giveCost: '',
+  campusId: 0,
+  recommenderUserId: '',
+  remark: '',
+});
+function openEnroll(): void {
+  enrollForm.userId = studentOpts.value[0]?.userId ?? '';
+  enrollForm.pay = '';
+  enrollForm.lessonCost = '';
+  enrollForm.number = 1;
+  enrollForm.giveNumber = '';
+  enrollForm.discountCash = '';
+  enrollForm.giveCost = '';
+  enrollForm.campusId = scope.current.campusId;
+  enrollForm.recommenderUserId = '';
+  enrollForm.remark = '';
+  enrollVisible.value = true;
+}
+async function doEnroll(): Promise<void> {
+  if (!selectedCourseId.value) return;
+  if (!enrollForm.userId || !enrollForm.pay || !enrollForm.lessonCost || enrollForm.number <= 0) {
+    ElMessage_('warning', '请填全学生/课程费用/单节课时费用/课次');
+    return;
+  }
+  enrollBusy.value = true;
+  try {
+    const r = await enrollStudent(selectedCourseId.value, {
+      userId: enrollForm.userId,
+      pay: enrollForm.pay,
+      lessonCost: enrollForm.lessonCost,
+      number: enrollForm.number,
+      ...(enrollForm.giveNumber ? { giveNumber: Number(enrollForm.giveNumber) } : {}),
+      ...(enrollForm.discountCash ? { discountCash: enrollForm.discountCash } : {}),
+      ...(enrollForm.giveCost ? { giveCost: enrollForm.giveCost } : {}),
+      ...(enrollForm.campusId ? { campusId: enrollForm.campusId } : {}),
+      ...(enrollForm.recommenderUserId ? { recommenderUserId: enrollForm.recommenderUserId } : {}),
+      ...(enrollForm.remark ? { remark: enrollForm.remark } : {}),
+    });
+    if (r.code === 200) {
+      ElMessage_('success', '报名成功(仅记应收/实收快照,不写资金流水、不动余额;真实收款走财务域)');
+      enrollVisible.value = false;
+      void loadStudents();
+      void fetchRecords(); // 学生数变化
+    } else {
+      // 3143 重复报名(专用码)/ 2001 参数缺失:读 code 直显
+      ElMessage_('warning', r.message || `报名失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    enrollBusy.value = false;
+  }
+}
+
+/* ================= ⑥ 欠费列表(端点 55/56;课程域全局,不随选中课程过滤) ================= */
+const { rows: arrears, total: arrTotal, page: arrPage, pageSize: arrPageSize, loading: arrLoading, error: arrError, fetchList: fetchArrears } =
+  useListPage<ArrearsDTO>({
+    fetcher: (p, ps) => listArrears({ ...scope.toQuery(), page: p, pageSize: ps }),
+  });
+onMounted(() => {
+  void fetchArrears();
+});
+const arrColumns: ListColumn<ArrearsDTO>[] = [
+  { prop: 'studentId', label: '学生', minWidth: 140, formatter: (r) => studentName(r.studentId) },
+  { prop: 'courseId', label: '课程 id', minWidth: 90 },
+  { prop: 'remainderReceivable', label: '还需缴费', minWidth: 110 },
+  {
+    prop: 'counteracted',
+    label: '核销状态',
+    minWidth: 100,
+    formatter: (r) => (r.counteracted ? '有实收/核销' : '未核销'),
+  },
+];
+
+/** 欠费核销对话框(端点 56;单事务) */
+const actVisible = ref(false);
+const actBusy = ref(false);
+const actTarget = ref<ArrearsDTO | null>(null);
+const actMoney = ref('');
+function openCounteract(row: ArrearsDTO): void {
+  actTarget.value = row;
+  actMoney.value = row.remainderReceivable;
+  actVisible.value = true;
+}
+async function doCounteract(): Promise<void> {
+  const t = actTarget.value;
+  if (!t || !actMoney.value || Number(actMoney.value) <= 0) {
+    ElMessage_('warning', '请输入有效核销金额(>0)');
+    return;
+  }
+  actBusy.value = true;
+  try {
+    const r = await counteractArrears({
+      studentId: t.studentId,
+      institutionId: scope.current.institutionId,
+      campusId: scope.current.campusId,
+      money: actMoney.value,
+    });
+    if (r.code === 200 && r.data) {
+      ElMessage_('success', `核销完成:${studentName(t.studentId)} 核销 ${r.data.amount}`);
+      actVisible.value = false;
+      arrPage.value = 1;
+      void fetchArrears();
+    } else {
+      ElMessage_('warning', r.message || `核销失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    actBusy.value = false;
+  }
+}
 </script>
 
 <template>
   <div class="schedule-page">
+    <!-- ① 课程列表(端点 44;行点击 = 选中课程,驱动 ②③④⑤) -->
     <ListPage
       v-model:page="page"
       v-model:page-size="pageSize"
-      :rows="rows"
+      :rows="courses"
       :total="total"
       :loading="loading"
       :error="error"
       :columns="columns"
       @search="onSearch"
+      @row-click="(row: CourseDTO) => onSelectCourse(row.id)"
     >
       <template #toolbar>
         <el-input v-model="filters.keyword" placeholder="搜索课程名称" clearable class="toolbar-input" />
         <el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
-          <el-option v-for="(t, k) in statusText" :key="k" :value="Number(k)" :label="t" />
+          <el-option v-for="(t, k) in courseStatusText" :key="k" :value="Number(k)" :label="t" />
         </el-select>
       </template>
     </ListPage>
+
+    <!-- ② 排班规则区(端点 46/47) -->
+    <div class="schedule-page__section">
+      <div class="schedule-page__head">
+        <span class="schedule-page__title">排班规则</span>
+        <el-select v-model="selectedCourseId" filterable placeholder="选择课程" class="schedule-page__course-select">
+          <el-option v-for="c in courses" :key="c.id" :value="c.id" :label="c.name" />
+        </el-select>
+        <el-button type="primary" :disabled="!selectedCourseId" @click="openClass">新建排班规则</el-button>
+      </div>
+      <el-table v-if="selectedCourse" :data="classRows" :loading="classLoading" size="small" border>
+        <el-table-column prop="classId" label="规则 id" width="90" />
+        <el-table-column prop="teacherId" label="老师" min-width="120">
+          <template #default="{ row }">{{ teacherName(row.teacherId) }}</template>
+        </el-table-column>
+        <el-table-column prop="week" label="周" min-width="120">
+          <template #default="{ row }">{{ weekText(row.week) }}</template>
+        </el-table-column>
+        <el-table-column prop="start" label="上课时间" width="150">
+          <template #default="{ row }">{{ formatDateTime(row.start) }}</template>
+        </el-table-column>
+        <el-table-column prop="end" label="下课时间" width="150">
+          <template #default="{ row }">{{ formatDateTime(row.end) }}</template>
+        </el-table-column>
+        <el-table-column prop="campusId" label="校区" min-width="110">
+          <template #default="{ row }">{{ campusName(row.campusId) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-else description="选择课程后查看/管理排班规则" :image-size="60" />
+    </div>
+
+    <!-- ③ 课表区(端点 48/49/50/51) -->
+    <div class="schedule-page__section">
+      <div class="schedule-page__head">
+        <span class="schedule-page__title">课表(排班实例)</span>
+        <el-date-picker
+          v-model="recFilters.range"
+          type="daterange"
+          value-format="YYYY-MM-DD"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期(含当日)"
+          class="schedule-page__date-range"
+        />
+        <el-select v-model="recFilters.status" placeholder="状态" clearable class="schedule-page__rec-status">
+          <el-option v-for="(t, k) in recStatusText" :key="k" :value="Number(k)" :label="t" />
+        </el-select>
+        <el-button @click="onRecSearch" :disabled="!selectedCourseId">刷新</el-button>
+        <el-button type="warning" :disabled="!selectedCourseId" @click="openGenerate">生成课表</el-button>
+      </div>
+      <div v-if="selectedCourse" class="schedule-page__rec-table">
+        <ListPage
+          v-model:page="recPage"
+          v-model:page-size="recPageSize"
+          :rows="records"
+          :total="recTotal"
+          :loading="recLoading"
+          :error="recError"
+          :columns="recColumns"
+        >
+        </ListPage>
+        <!-- 行操作(ListPage 无操作列插槽,按批次 B 模式放在表下逐行) -->
+        <div class="schedule-page__rec-actions">
+          <div v-for="rec in records" :key="rec.recordId" class="schedule-page__rec-row">
+            <span class="schedule-page__rec-label">
+              课表 {{ rec.recordId }} · {{ formatDateTime(rec.startTime) }} · {{ recStatusText[rec.status] ?? rec.status }}
+            </span>
+            <el-button
+              v-if="rec.status === 0"
+              size="small"
+              type="primary"
+              :loading="startBusy"
+              @click="doStart(rec)"
+            >
+              开始上课
+            </el-button>
+            <template v-if="rec.status === 1">
+              <el-button size="small" type="warning" @click="openAttendance(rec)">打卡</el-button>
+              <el-button
+                size="small"
+                type="danger"
+                :loading="finishBusy"
+                :disabled="finishBusy"
+                @click="doFinish(rec)"
+              >
+                结束上课
+              </el-button>
+            </template>
+            <span v-if="rec.status === 2" class="schedule-page__rec-done">已结算</span>
+          </div>
+          <el-empty v-if="records.length === 0" description="暂无课表(可点「生成课表」)" :image-size="60" />
+        </div>
+      </div>
+      <el-empty v-else description="选择课程后查看课表" :image-size="60" />
+    </div>
+
+    <!-- ⑤ 报课学生区(端点 53/54) -->
+    <div class="schedule-page__section">
+      <div class="schedule-page__head">
+        <span class="schedule-page__title">报课学生</span>
+        <el-button type="primary" :disabled="!selectedCourseId" @click="openEnroll">报名</el-button>
+        <span class="schedule-page__hint">欠费列 = remainderReceivable(还需缴费);报名不写资金流水、不动余额</span>
+      </div>
+      <el-table v-if="selectedCourse" :data="stuRows" :loading="stuLoading" size="small" border>
+        <el-table-column prop="userId" label="学生" min-width="150">
+          <template #default="{ row }">{{ row.realName ?? row.userId }}({{ row.userId.slice(0, 8) }}…)</template>
+        </el-table-column>
+        <el-table-column prop="pay" label="课程费用" width="110" />
+        <el-table-column prop="cost" label="课时费用" width="110" />
+        <el-table-column prop="giveCost" label="赠送" width="100" />
+        <el-table-column prop="remainderReceivable" label="还需缴费" width="110" />
+        <el-table-column prop="status" label="状态" width="90">
+          <template #default="{ row }">{{ stuStatusText[row.status] ?? String(row.status) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-else description="选择课程后查看报课学生" :image-size="60" />
+    </div>
+
+    <!-- ⑥ 欠费列表(端点 55/56;课程域全局,不随选中课程过滤) -->
+    <div class="schedule-page__section">
+      <div class="schedule-page__head">
+        <span class="schedule-page__title">欠费列表(全局)</span>
+        <span class="schedule-page__hint">口径 = remainderReceivable &gt; 0;核销为单事务</span>
+      </div>
+      <ListPage
+        v-model:page="arrPage"
+        v-model:page-size="arrPageSize"
+        :rows="arrears"
+        :total="arrTotal"
+        :loading="arrLoading"
+        :error="arrError"
+        :columns="arrColumns"
+      >
+      </ListPage>
+      <div class="schedule-page__arr-actions">
+        <div v-for="a in arrears" :key="a.studentId + '-' + a.courseId" class="schedule-page__rec-row">
+          <span class="schedule-page__rec-label">
+            {{ studentName(a.studentId) }} · 课程 {{ a.courseId }} · 欠费 {{ formatMoney(parseFloat(a.remainderReceivable)) }} 元
+          </span>
+          <el-button size="small" type="primary" @click="openCounteract(a)">核销</el-button>
+        </div>
+        <el-empty v-if="arrears.length === 0" description="当前无欠费记录" :image-size="60" />
+      </div>
+    </div>
+
+    <!-- 新建排班规则对话框(端点 47) -->
+    <el-dialog v-model="classVisible" title="新建排班规则" width="520px">
+      <el-form label-width="90px">
+        <el-form-item label="课程">
+          <span>{{ selectedCourse?.name }}</span>
+        </el-form-item>
+        <el-form-item label="授课老师">
+          <el-select v-model="classForm.teacher" filterable placeholder="选择老师" style="width: 100%">
+            <el-option v-for="m in staffOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="上课周">
+          <el-select v-model="classForm.weeks" multiple placeholder="选择周(-1 = 全周,与具体周互斥)" style="width: 100%">
+            <el-option :value="-1" label="全周(-1)" />
+            <el-option v-for="(t, k) in WEEK_NAMES" :key="k" :value="Number(k)" :label="t" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="上课时间">
+          <el-time-picker v-model="classForm.start" value-format="HH:mm:ss" format="HH:mm:ss" placeholder="选择上课时间" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="下课时间">
+          <el-time-picker v-model="classForm.end" value-format="HH:mm:ss" format="HH:mm:ss" placeholder="选择下课时间" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="校区">
+          <el-select v-model="classForm.campusId" style="width: 100%">
+            <el-option :value="0" label="机构级" />
+            <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+          </el-select>
+        </el-form-item>
+        <el-alert title="冲突(同老师+周+校区已有规则)→ 3122;无 room 字段(v1.8:源列只有 address)" type="info" :closable="false" />
+      </el-form>
+      <template #footer>
+        <el-button @click="classVisible = false">取消</el-button>
+        <el-button type="primary" :loading="classBusy" @click="doCreateClass">创建</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 生成课表对话框(端点 49;幂等) -->
+    <el-dialog v-model="genVisible" title="生成课表" width="420px">
+      <el-form label-width="100px">
+        <el-form-item label="课程">
+          <span>{{ selectedCourse?.name }}</span>
+        </el-form-item>
+        <el-form-item label="生成天数">
+          <el-input-number v-model="genDays" :min="1" :max="60" />
+        </el-form-item>
+        <el-alert title="幂等:同课程+起始时间已生成的课表不重复插入;changed=false 表示本次无新增" type="info" :closable="false" />
+      </el-form>
+      <template #footer>
+        <el-button @click="genVisible = false">取消</el-button>
+        <el-button type="primary" :loading="genBusy" @click="doGenerate">生成</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- ④ 打卡对话框(端点 52;逐学生 type/pay/cost/giveCost) -->
+    <el-dialog v-model="attVisible" :title="`打卡 · 课表 ${attRecord?.recordId ?? ''}`" width="640px">
+      <el-table :data="attRows" size="small" border max-height="360">
+        <el-table-column prop="name" label="学生" min-width="130" />
+        <el-table-column label="类型" width="110">
+          <template #default="{ row }">
+            <el-select v-model="row.type" size="small">
+              <el-option v-for="(t, k) in attTypeText" :key="k" :value="Number(k)" :label="t" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="课程费用" width="120">
+          <template #default="{ row }">
+            <el-input v-model="row.pay" size="small" placeholder="金额" />
+          </template>
+        </el-table-column>
+        <el-table-column label="课时费用" width="120">
+          <template #default="{ row }">
+            <el-input v-model="row.cost" size="small" placeholder="金额" />
+          </template>
+        </el-table-column>
+        <el-table-column label="赠送" width="110">
+          <template #default="{ row }">
+            <el-input v-model="row.giveCost" size="small" placeholder="可空" />
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-alert
+        v-if="attRows.length === 0"
+        title="学生初值加载中(取课程级报课学生;提交时按该课实际学生匹配)"
+        type="info"
+        :closable="false"
+        class="att-loading"
+      />
+      <template #footer>
+        <el-button @click="attVisible = false">取消</el-button>
+        <el-button type="primary" :loading="attBusy" :disabled="attRows.length === 0" @click="doAttendance">提交打卡</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 报名对话框(端点 54;不写资金流水、不动余额) -->
+    <el-dialog v-model="enrollVisible" title="报名 / 补报名" width="520px">
+      <el-form label-width="110px">
+        <el-form-item label="课程">
+          <span>{{ selectedCourse?.name }}</span>
+        </el-form-item>
+        <el-form-item label="学生">
+          <el-select v-model="enrollForm.userId" filterable placeholder="选择学生" style="width: 100%">
+            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="课程费用">
+          <el-input v-model="enrollForm.pay" placeholder="必填,如 10000.00" />
+        </el-form-item>
+        <el-form-item label="单节课时费用">
+          <el-input v-model="enrollForm.lessonCost" placeholder="必填,如 200.00" />
+        </el-form-item>
+        <el-form-item label="课次">
+          <el-input-number v-model="enrollForm.number" :min="1" />
+        </el-form-item>
+        <el-form-item label="赠送课次">
+          <el-input v-model="enrollForm.giveNumber" placeholder="可空" />
+        </el-form-item>
+        <el-form-item label="现金折扣">
+          <el-input v-model="enrollForm.discountCash" placeholder="可空,金额" />
+        </el-form-item>
+        <el-form-item label="赠送金额">
+          <el-input v-model="enrollForm.giveCost" placeholder="可空,金额" />
+        </el-form-item>
+        <el-form-item label="校区">
+          <el-select v-model="enrollForm.campusId" style="width: 100%">
+            <el-option :value="0" label="机构级" />
+            <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="推荐人">
+          <el-select v-model="enrollForm.recommenderUserId" filterable clearable placeholder="可空" style="width: 100%">
+            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="enrollForm.remark" placeholder="可空" />
+        </el-form-item>
+        <el-alert
+          title="报名只记应收/实收快照:不写资金流水、不动余额(真实收款走财务域);重复报名 → 3143"
+          type="info"
+          :closable="false"
+        />
+      </el-form>
+      <template #footer>
+        <el-button @click="enrollVisible = false">取消</el-button>
+        <el-button type="primary" :loading="enrollBusy" @click="doEnroll">确认报名</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 欠费核销对话框(端点 56;单事务) -->
+    <el-dialog v-model="actVisible" title="欠费核销" width="420px">
+      <el-form label-width="100px">
+        <el-form-item label="学生">
+          <span>{{ studentName(actTarget?.studentId ?? null) }}</span>
+        </el-form-item>
+        <el-form-item label="课程 id">
+          <span>{{ actTarget?.courseId }}</span>
+        </el-form-item>
+        <el-form-item label="当前欠费">
+          <span>{{ actTarget?.remainderReceivable }}</span>
+        </el-form-item>
+        <el-form-item label="核销金额">
+          <el-input v-model="actMoney" placeholder="金额(>0)" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="actVisible = false">取消</el-button>
+        <el-button type="primary" :loading="actBusy" @click="doCounteract">核销</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -60,4 +876,61 @@ onMounted(() => {
 .schedule-page {
   width: 100%;
 }
+.schedule-page__section {
+  margin-top: 16px;
+  padding-top: 12px;
+  border-top: 1px solid var(--app-color-border, #e3e7ea);
+}
+.schedule-page__head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.schedule-page__title {
+  font-weight: 600;
+  min-width: 130px;
+}
+.schedule-page__course-select {
+  width: 240px;
+}
+.schedule-page__date-range {
+  width: 260px;
+}
+.schedule-page__rec-status {
+  width: 120px;
+}
+.schedule-page__hint {
+  font-size: var(--app-font-sm);
+  color: var(--app-color-text-2, #5d6672);
+}
+.schedule-page__rec-actions {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.schedule-page__arr-actions {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.schedule-page__rec-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.schedule-page__rec-label {
+  font-size: var(--app-font-md);
+  color: var(--app-color-text-2, #5d6672);
+  min-width: 280px;
+}
+.schedule-page__rec-done {
+  font-size: var(--app-font-sm);
+  color: var(--app-color-text-2, #5d6672);
+}
+.att-loading {
+  margin-top: 12px;
+}
 </style>