فهرست منبع

feat(academic): 落地 PC 端 mock 联调(axios adapter 拦截,58 端点 + 8 异常分支)

按 academic-mock-plan v1.0 全量实施(M1~M8):
- 零依赖:http 实例挂自定义 adapter,dev + VITE_API_MOCK 门控,生产 DCE 不进 dist
- 信封/金额/时间辅助(envelope/money/time)+ 确定性内存数据(data,每用户 320 流水验证分页边界)
- 7 域路由:auth(5) / finance(17, 端点1~16,21) / activities(16, 22~37) /
  divide-into(6, 38~43) / courses(13, 44~56) / payroll(3, 58~60) / maintenance(3, 61~63)
- 异常分支:1001/1004/3102/3103(warn)/3111/3112/3120/3121/3122 + repeated=true 幂等回放
- 维护型端点 super_admin 门禁(requireRole)
- 门禁:vue-tsc 0 错 / pnpm build 0 错 / dist 无 mock 特征串(DCE 验证通过)

.env.example 补 VITE_API_MOCK 说明;.env.development(gitignore,本地开 mock)
Q 17 ساعت پیش
والد
کامیت
32ed17b544

+ 7 - 0
.env.example

@@ -3,3 +3,10 @@ VITE_API_BASE=
 
 # dev 联调时 /api 代理目标(vite dev 用;可用环境变量覆盖,默认 http://127.0.0.1:8080)
 # VITE_API_PROXY_TARGET=http://127.0.0.1:8080
+
+# academic 域 mock 联调开关(仅 dev 生效;详见 unified/03-migration/academic-mock-plan.md)
+#   true  = http 实例挂 mock adapter,/admin/* 全部走内存 mock(后端 AC-12/AC-13 未就绪前联调用)
+#   false = 走真实后端(vite proxy → VITE_API_PROXY_TARGET)
+# 生产 build 恒不注入此变量(.env.production 无此项)→ import.meta.env.DEV=false → adapter 不挂载、
+# mock 模块经 DCE 不进 dist。仅在 .env.development / .env.local 等本地文件置 true,勿提交。
+# VITE_API_MOCK=true

+ 18 - 0
src/api/http.ts

@@ -196,3 +196,21 @@ http.interceptors.response.use(
     return Promise.reject(error);
   },
 );
+
+/**
+ * mock 联调装配(academic-mock-plan §2/§9 M7:仅 dev + env 门控)。
+ *
+ * 为何用动态 import 而非静态 import:
+ *   - 生产 build 时 `import.meta.env.DEV` 被 Vite 静态替换为 `false`、`VITE_API_MOCK` 为 undefined
+ *     (.env.production 不注入),整条 if 语句被 DCE 消除 → **动态 import 语句一并消失** →
+ *     mock 模块(含 data.ts 的模块级 IIFE 副作用)根本不进 dist,保证"dist 无 mock 特征串"门禁。
+ *   - 静态 import 会被 rollup 保留(data.ts 有顶层副作用,side-effect 分析不清除),违反该门禁。
+ * 为何异步(.then)可接受:首个真实请求(登录 restoreMe)发生在用户交互/路由守卫之后,
+ *   远晚于模块加载完成,microtask 内完成 adapter 挂载无时序风险。
+ * 挂载后行为:http 实例的所有请求走 mock adapter(不发真网络),响应拦截器(toast/1001 refresh)原样保留。
+ */
+if (import.meta.env.DEV && import.meta.env.VITE_API_MOCK === 'true') {
+  import('@/api/mock').then(({ registerMock }) => {
+    registerMock(http);
+  });
+}

+ 170 - 0
src/api/mock/adapter.ts

@@ -0,0 +1,170 @@
+/**
+ * adapter.ts —— axios 自定义 adapter(academic-mock-plan §2:dev 模式拦截请求返回 mock 信封)。
+ *
+ * 机制:
+ *   - 给 http 实例挂 `adapter`,请求不真正发网络,直接按「method + path 路由表」返回 mock 响应;
+ *   - 入参来自 axios config:url / method / params(query) / data(body,默认 transformRequest 已序列化为 JSON 串);
+ *   - 出参 = axios 响应形态 { data: 信封, status: 200, statusText, headers, config, request },
+ *     http.ts 的响应拦截器读 response.data(信封)——业务错误 toast / 1001 refresh 链路**原样保留**;
+ *   - path 支持 `:param`(如 `/api/admin/finance/accounts/:userId`),命中后回填到 handler 的 params。
+ *
+ * wire 路径约定:路由表路径 = 前端 api 文件实际发送的 url(`/api/auth/*`、`/api/admin/*`、
+ *   `/api/student/*`、`/api/pay/*`);契约文档里的裸路径(`/admin/...`)在本 mock 中统一为 `/api/admin/...`
+ *   (对齐 vite proxy 的 /api 前缀 + 既有 login.ts 的 /api/auth/* 惯例)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(http.ts 动态 import 门控),不进 dist。
+ */
+import type { AxiosAdapter, InternalAxiosRequestConfig } from 'axios';
+import type { R } from '@/types/api';
+
+/** 路由 handler 收到的上下文(query 已拍平为字符串,path 参数已解析,body 已 JSON.parse) */
+export interface MockCtx {
+  /** query 参数(含 page/pageSize 等;值恒 string) */
+  query: Record<string, string>;
+  /** body(POST/PUT 的 JSON 对象;GET 为 undefined) */
+  body: Record<string, unknown> | undefined;
+  /** path 参数(如 :userId 解析出的值) */
+  params: Record<string, string>;
+  /** Authorization 头(用于 1001 模拟:缺失或 mock-invalid-token → 未登录) */
+  auth: string | undefined;
+}
+
+/** 一条 mock 路由 */
+export interface MockRoute {
+  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
+  /** 契约路径(裸 form,含 :param) */
+  path: string;
+  handler: (ctx: MockCtx) => R<unknown>;
+}
+
+/** 路由表(method+path → route);由 index.ts 聚合各域路由后注入 */
+let routes: MockRoute[] = [];
+
+/** 注入路由表(index.ts 调一次)。入参:MockRoute[]。出参:无。 */
+export function setRoutes(list: MockRoute[]): void {
+  routes = list;
+}
+
+/** 模拟网络时延(ms),让 loading 态可见;0 = 即时。 */
+const LATENCY_MS = 200;
+
+/**
+ * 归一化请求 url(去 query / 尾斜杠)。入参:config.url。出参:string。
+ *
+ * wire 路径约定(与前端 api 文件发送的 url 完全一致,路由表按此匹配):
+ *   - 认证:  /api/auth/*(对齐既有 login.ts)
+ *   - 教务PC:/api/admin/*(契约 /admin/* 端加 /api 前缀,对齐 vite proxy /api)
+ *   - 移动:  /api/student/*
+ *   - 支付:  /api/pay/*(契约 /api/pay/* 原样,公开端点)
+ * config.url 本身即裸路径(axios 把 query 放 config.params),这里仅去可能附带的 query/尾斜杠。
+ */
+function normalizeUrl(url: string | undefined): string {
+  if (!url) return '';
+  let p = url.split('?')[0] ?? '';
+  if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
+  return p;
+}
+
+/** 把 `:param` 路径编译为正则 + 参数名列表。入参:path。出参:{re, keys}。 */
+function compile(path: string): { re: RegExp; keys: string[] } {
+  const keys: string[] = [];
+  const re = new RegExp(
+    '^' +
+      path
+        .split('/')
+        .map((seg) => {
+          if (seg.startsWith(':')) {
+            keys.push(seg.slice(1));
+            return '([^/]+)';
+          }
+          return seg;
+        })
+        .join('/') +
+      '(/.*)?$',
+  );
+  return { re, keys };
+}
+
+/**
+ * 创建 mock adapter(挂到 http 实例)。
+ *
+ * 入参:
+ *   - config InternalAxiosRequestConfig:axios 内部配置(url/method/params/data/headers)。
+ *
+ * 出参:
+ *   - Promise:resolve axios 响应形态(data=信封);未命中路由时返回 404 信封(code=3001 资源不存在)。
+ *
+ * 异常场景:
+ *   - data 非法 JSON:body 置 undefined,handler 按参数缺失(2001)处理。
+ */
+export function createMockAdapter(): AxiosAdapter {
+  return (config: InternalAxiosRequestConfig): Promise<never> => {
+    // 仅 resolve(mock 恒 HTTP 200 + body code;业务错误/资源不存在都走 resolve 信封,不 reject)
+    return new Promise((resolve) => {
+      const method = (config.method ?? 'get').toUpperCase() as MockRoute['method'];
+      const path = normalizeUrl(config.url);
+
+      // 解析 query(config.params 可能为对象)
+      const query: Record<string, string> = {};
+      const rawParams = config.params;
+      if (rawParams && typeof rawParams === 'object') {
+        for (const [k, v] of Object.entries(rawParams)) {
+          if (v !== undefined && v !== null && v !== '') query[k] = String(v);
+        }
+      }
+      // 解析 body(config.data 为 JSON 串或对象)
+      let body: Record<string, unknown> | undefined;
+      if (config.data !== undefined && config.data !== null) {
+        if (typeof config.data === 'object') body = config.data as Record<string, unknown>;
+        else {
+          try {
+            body = JSON.parse(config.data) as Record<string, unknown>;
+          } catch {
+            body = undefined;
+          }
+        }
+      }
+      const auth = config.headers?.Authorization as string | undefined;
+
+      // 路由匹配
+      let hit: { route: MockRoute; params: Record<string, string> } | undefined;
+      for (const route of routes) {
+        if (route.method !== method) continue;
+        const { re, keys } = compile(route.path);
+        const m = re.exec(path);
+        if (m) {
+          const params: Record<string, string> = {};
+          keys.forEach((k, i) => {
+            params[k] = m[i + 1] ?? '';
+          });
+          hit = { route, params };
+          break;
+        }
+      }
+
+      setTimeout(() => {
+        if (!hit) {
+          // 未命中:按"资源不存在"兜底(前端联调时能立即发现路径写错)
+          resolve({
+            data: { success: false, code: 3001, message: `mock 未覆盖端点 ${method} ${path}` },
+            status: 200,
+            statusText: 'OK',
+            headers: {},
+            config,
+            request: {},
+          } as never);
+          return;
+        }
+        const envelope = hit.route.handler({ query, body, params: hit.params, auth });
+        resolve({
+          data: envelope,
+          status: 200,
+          statusText: 'OK',
+          headers: {},
+          config,
+          request: {},
+        } as never);
+      }, LATENCY_MS);
+    });
+  };
+}

+ 580 - 0
src/api/mock/data.ts

@@ -0,0 +1,580 @@
+/**
+ * data.ts —— mock 数据源(academic-mock-plan §6 数据策略 + §4 行为契约)。
+ *
+ * 定位:dev 会话内单例内存数据(刷新即重置,不落 localStorage)。承载:
+ *   - 固定作用域实体(3 校区 / 10 用户,与原型一致);
+ *   - 账户余额(全局 + 校区,分整数);
+ *   - 资金流水(每用户 320 条 → 覆盖分页边界:total>200 / pageSize 上限 200 / 越界页空集);
+ *   - 活动 / 课程排班 / 分账树 / 课酬方案 / 资金工单(工单驱动资金操作);
+ *   - 幂等存储(同 requestId 重复提交 → repeated=true)+ 余额/流水的可变状态(充值/退费/清零/转结改余额并追加流水)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md,冻结):
+ *   - 流水 type 编码 = §10.2 编码表(100 充值 + / 101,201 消费 − / 207 退费 − / 208 清零 − / 106,206 转结 等);
+ *   - 金额恒**分**整数存、对外输出**字符串两位小数**(money.ts);
+ *   - 时间恒 RFC3339 +08:00(time.ts);用户 ID 字符串、业务主键 int64、单号 bizNo 字符串。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(http.ts 动态 import 门控),不进 dist。
+ */
+import { fmt, toCents } from '@/api/mock/money';
+import { rfc3339 } from '@/api/mock/time';
+
+/* ================= 种子随机(确定性,保证每次会话数据一致) ================= */
+function mulberry32(seed: number): () => number {
+  let a = seed | 0;
+  return () => {
+    a = (a + 0x6d2b79f5) | 0;
+    let t = Math.imul(a ^ (a >>> 15), 1 | a);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+const rand = mulberry32(20261109);
+const randInt = (min: number, max: number): number => Math.floor(rand() * (max - min + 1)) + min;
+const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)];
+
+/** 基准时刻:2026-11-09T12:00:00+08:00(数据在其前 60 天分布,确定性) */
+const BASE_MS = Date.parse('2026-11-09T12:00:00+08:00');
+
+/* ================= 作用域实体 ================= */
+export interface MockCampus {
+  campusId: number;
+  institutionId: number;
+  name: string;
+}
+export interface MockUser {
+  /** canonical_user_id(ULID 26 位字符串,契约 §1.5) */
+  userId: string;
+  realName: string;
+  personType: string;
+  /** 脱敏手机号 */
+  phone: string;
+  /** 归属校区(mock 简化:每用户主校区) */
+  campusId: number;
+  role: string;
+}
+
+export const campuses: MockCampus[] = [
+  { campusId: 11, institutionId: 1, name: '滨江校区' },
+  { campusId: 12, institutionId: 1, name: '城西校区' },
+  { campusId: 13, institutionId: 1, name: '高新校区' },
+];
+
+const ulid = (n: number): string => `01J2M${String(n).padStart(19, '0')}`.slice(0, 26);
+
+export const users: MockUser[] = [
+  { userId: ulid(101), realName: '张婷', personType: 'staff', phone: '135****3322', campusId: 11, role: 'teacher' },
+  { userId: ulid(102), realName: '李强', personType: 'staff', phone: '135****4477', campusId: 12, role: 'teacher' },
+  { userId: ulid(201), realName: '王梓涵', personType: 'student', phone: '139****2211', campusId: 11, role: 'student' },
+  { userId: ulid(202), realName: '陈一诺', personType: 'student', phone: '137****6644', campusId: 11, role: 'student' },
+  { userId: ulid(203), realName: '李沐宸', personType: 'student', phone: '138****9900', campusId: 12, role: 'student' },
+  { userId: ulid(204), realName: '赵雨桐', personType: 'student', phone: '136****5511', campusId: 11, role: 'student' },
+  { userId: ulid(205), realName: '刘子墨', personType: 'student', phone: '133****7788', campusId: 12, role: 'student' },
+  { userId: ulid(301), realName: '周敏', personType: 'staff', phone: '132****8899', campusId: 11, role: 'academic' },
+  { userId: ulid(302), realName: '财务管理员', personType: 'staff', phone: '131****6633', campusId: 11, role: 'finance' },
+  { userId: ulid(303), realName: '超级管理员', personType: 'staff', phone: '130****1122', campusId: 11, role: 'super_admin' },
+];
+
+/** 合法作用域校验(mock 侧 1004 越界判定:campusId 不在集合或 institutionId 不在 {1,0} → 越界)。 */
+export function inScope(institutionId: number, campusId: number | undefined | null): boolean {
+  if (institutionId !== 1 && institutionId !== 0) return false;
+  if (campusId === undefined || campusId === null || campusId === 0) return true;
+  return campuses.some((c) => c.campusId === campusId);
+}
+
+export function findUser(userId: string): MockUser | undefined {
+  return users.find((u) => u.userId === userId);
+}
+export function findCampus(campusId: number): MockCampus | undefined {
+  return campuses.find((c) => c.campusId === campusId);
+}
+
+/* ================= 账户余额(分整数;全局 + 校区) ================= */
+interface Account {
+  totalBalance: number;
+  campusBalance: number;
+}
+const accounts = new Map<string, Account>(); // key = `${userId}@${campusId}`
+
+function accountKey(userId: string, campusId: number): string {
+  return `${userId}@${campusId}`;
+}
+function ensureAccount(userId: string, campusId: number, total: number, campus: number): Account {
+  const k = accountKey(userId, campusId);
+  let a = accounts.get(k);
+  if (!a) {
+    a = { totalBalance: total, campusBalance: campus };
+    accounts.set(k, a);
+  }
+  return a;
+}
+function getUserTotal(userId: string): number {
+  let sum = 0;
+  for (const c of campuses) sum += accounts.get(accountKey(userId, c.campusId))?.totalBalance ?? 0;
+  return sum;
+}
+
+/** 初始化账户:学生/老师有正余额(可退/可清零),1 名 0 余额(3102 触发),1 名负余额。 */
+(function seedAccounts() {
+  const students = users.filter((u) => u.personType === 'student');
+  students.forEach((u, i) => {
+    // 王梓涵(201) 滨江:正余额;陈一诺(202) 滨江:0 余额(3102);其余正
+    const campusTotal = i === 1 ? 0 : randInt(800, 4000) * 100; // 800~4000 元
+    ensureAccount(u.userId, u.campusId, campusTotal, i === 1 ? 0 : Math.floor(campusTotal * 0.6));
+    // 部分学生在另一校区也有账户
+    if (i % 2 === 0) {
+      const other = campuses.find((c) => c.campusId !== u.campusId)!;
+      ensureAccount(u.userId, other.campusId, Math.floor(campusTotal * 0.4), 0);
+    }
+  });
+  // 李沐宸(203) 额外给一个负余额校区(3120 清零前置:校区余额>全局 或 余额≤0 场景)
+  ensureAccount(ulid(203), 13, -5000, 30000);
+})();
+
+export function getAccount(userId: string, campusId: number): { total: number; campus: number } {
+  const a = accounts.get(accountKey(userId, campusId));
+  return { total: a?.totalBalance ?? 0, campus: a?.campusBalance ?? 0 };
+}
+/** 当前作用域内的全局余额(campusId=0 表示机构级聚合)。 */
+export function getGlobalBalance(userId: string, campusId: number): number {
+  if (campusId && campusId > 0) return getAccount(userId, campusId).total;
+  return getUserTotal(userId);
+}
+
+/* ================= 资金流水(t_user_expense_log 形态) ================= */
+export interface ExpenseLog {
+  id: number;
+  userId: string;
+  institutionId: number;
+  campusId: number;
+  /** 流水 type(§10.2 编码表) */
+  type: number;
+  /** 金额(分,含方向符号:充值/坏账 +,消费/退费/清零 −) */
+  money: number;
+  balanceAfter: number;
+  createAt: string;
+  remark: string;
+  requestId?: string;
+}
+
+const LOG_TYPE_REMARK: Record<number, string> = {
+  100: '充值',
+  101: '课程消费(校区)',
+  201: '课程消费(校区)',
+  104: '课程消费(学校)',
+  204: '课程消费(学校)',
+  207: '余额退费',
+  208: '余额清零',
+  106: '转结出',
+  206: '转结入',
+};
+const SEED_TYPES = [100, 101, 101, 201, 104, 207, 208] as const;
+const PER_USER_LOGS = 320; // > 200,覆盖分页边界
+
+let logSeq = 90000;
+const logs: ExpenseLog[] = [];
+
+(function seedLogs() {
+  for (const u of users) {
+    if (u.personType !== 'student') continue; // 仅学生造大量流水
+    let running = getAccount(u.userId, u.campusId).total;
+    for (let i = 0; i < PER_USER_LOGS; i++) {
+      const type = pick(SEED_TYPES);
+      // 金额 20~3000 元;充值/转结入 为正,其余为负
+      const yuan = randInt(20, 3000);
+      // 仅 100(充值)为正;其余消费/退费/清零为负(106/206 转结不入 SEED_TYPES,故不判)
+      const positive = type === 100;
+      const money = positive ? yuan * 100 : -yuan * 100;
+      running = Math.max(running + money, -200000);
+      const createAt = rfc3339(BASE_MS - i * 3600_000 * randInt(1, 30));
+      logs.push({
+        id: ++logSeq,
+        userId: u.userId,
+        institutionId: 1,
+        campusId: u.campusId,
+        type,
+        money,
+        balanceAfter: running,
+        createAt,
+        remark: LOG_TYPE_REMARK[type] ?? '流水',
+      });
+    }
+  }
+})();
+
+/** 按筛选条件取**完整**流水集(倒序;分页由 envelope.page 负责)。 */
+export interface LogFilter {
+  userId?: string;
+  type?: number;
+  from?: string;
+  to?: string;
+  institutionId?: number;
+  campusId?: number;
+}
+export function queryLogs(f: LogFilter): ExpenseLog[] {
+  const from = f.from ? Date.parse(`${f.from}T00:00:00+08:00`) : null;
+  const to = f.to ? Date.parse(`${f.to}T23:59:59.999+08:00`) : null;
+  return logs
+    .filter((l) => {
+      if (f.userId && l.userId !== f.userId) return false;
+      if (f.type !== undefined && l.type !== f.type) return false;
+      if (f.institutionId && f.institutionId !== 1) return false;
+      if (f.campusId && f.campusId !== 0 && l.campusId !== f.campusId) return false;
+      const t = Date.parse(l.createAt);
+      if (from !== null && t < from) return false;
+      if (to !== null && t > to) return false;
+      return true;
+    })
+    .sort((a, b) => (a.createAt < b.createAt ? 1 : a.createAt > b.createAt ? -1 : b.id - a.id));
+}
+
+/** 追加一条流水(写端点共用;返回新 logId)。 */
+export function appendLog(userId: string, campusId: number, type: number, money: number, remark: string, requestId?: string): ExpenseLog {
+  const a = ensureAccount(userId, campusId, 0, 0);
+  a.campusBalance += money;
+  a.totalBalance += money;
+  const log: ExpenseLog = {
+    id: ++logSeq,
+    userId,
+    institutionId: 1,
+    campusId,
+    type,
+    money,
+    balanceAfter: a.totalBalance,
+    createAt: rfc3339(BASE_MS),
+    remark,
+  };
+  if (requestId) log.requestId = requestId;
+  logs.unshift(log); // 新流水置顶(倒序查询天然靠前)
+  return log;
+}
+
+/* ================= 资金工单(t_user_balance_request) ================= */
+export interface BalanceRequest {
+  id: number;
+  bizNo: string;
+  type: number; // 100/101/202/203/300
+  userId: string;
+  institutionId: number;
+  campusId: number;
+  money: number; // 分
+  approved: 0 | 1 | -1 | -2; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
+  applicantId: string;
+  approverId: string | null;
+  remark: string;
+  createAt: string;
+}
+const requestTypes = [100, 101, 202, 203, 300] as const;
+let reqSeq = 5000;
+const requests: BalanceRequest[] = [];
+(function seedRequests() {
+  for (let i = 0; i < 60; i++) {
+    const u = pick(users.filter((x) => x.personType === 'student'));
+    const approved = pick([0, 0, 1, -1, -2] as const);
+    requests.push({
+      id: ++reqSeq,
+      bizNo: `REQ${(700000 + i).toString()}`,
+      type: pick(requestTypes),
+      userId: u.userId,
+      institutionId: 1,
+      campusId: u.campusId,
+      money: randInt(300, 5000) * 100,
+      approved,
+      applicantId: u.userId,
+      approverId: approved === 0 ? null : ulid(302),
+      remark: '工单',
+      createAt: rfc3339(BASE_MS - i * 7200_000),
+    });
+  }
+  requests.sort((a, b) => (a.createAt < b.createAt ? 1 : -1));
+})();
+export function queryRequests(f: { type?: number; approved?: number; userId?: string; institutionId?: number; campusId?: number }): BalanceRequest[] {
+  return requests.filter((r) => {
+    if (f.type !== undefined && r.type !== f.type) return false;
+    if (f.approved !== undefined && r.approved !== f.approved) return false;
+    if (f.userId && r.userId !== f.userId) return false;
+    if (f.institutionId && f.institutionId !== 1) return false;
+    if (f.campusId && f.campusId !== 0 && r.campusId !== f.campusId) return false;
+    return true;
+  });
+}
+export function findRequest(id: number): BalanceRequest | undefined {
+  return requests.find((r) => r.id === id);
+}
+export function createRequest(type: number, userId: string, campusId: number, money: number, remark: string, applicantId: string): BalanceRequest {
+  const r: BalanceRequest = {
+    id: ++reqSeq,
+    bizNo: `REQ${(700000 + reqSeq).toString()}`,
+    type,
+    userId,
+    institutionId: 1,
+    campusId,
+    money,
+    approved: 0,
+    applicantId,
+    approverId: null,
+    remark,
+    createAt: rfc3339(BASE_MS),
+  };
+  requests.unshift(r);
+  return r;
+}
+
+/* ================= 活动(t_courses_activity) ================= */
+export interface ActivitySign {
+  id: number;
+  activityId: number;
+  userId: string;
+  status: number; // 0 未审核 / 1 通过 / -1 取消
+  recommendUserId: string | null;
+  money: number; // 分
+  createAt: string;
+}
+export interface Activity {
+  id: number;
+  institutionId: number;
+  campusId: number;
+  type: 2 | 3 | 4; // 2 老带新 / 3 充值赠送 / 4 特殊费用
+  name: string;
+  status: 0 | 1; // 0 停用 / 1 启用(注意:status=0 仍允许报名,契约 §2.2)
+  numberSign: number;
+  total: number;
+  signed: number;
+  signable: boolean;
+  startAt: string;
+  endAt: string;
+  signeds: ActivitySign[];
+}
+let signSeq = 3000;
+function mkSign(activityId: number, userId: string, status: number, recommendUserId: string | null, money: number): ActivitySign {
+  return { id: ++signSeq, activityId, userId, status, recommendUserId, money, createAt: rfc3339(BASE_MS - randInt(1, 30) * 86400_000) };
+}
+const activities: Activity[] = [
+  {
+    id: 1, institutionId: 1, campusId: 11, type: 2, name: '老带新推荐(数学)', status: 1, numberSign: 1,
+    total: 100, signed: 6, signable: true, startAt: rfc3339(BASE_MS - 40 * 86400_000), endAt: rfc3339(BASE_MS + 20 * 86400_000),
+    signeds: [
+      mkSign(1, ulid(201), 1, null, 20000),
+      mkSign(1, ulid(202), 1, ulid(201), 20000),
+      mkSign(1, ulid(204), 0, ulid(201), 20000),
+      mkSign(1, ulid(203), -1, null, 20000),
+      mkSign(1, ulid(205), 1, null, 20000),
+      mkSign(1, ulid(201), 1, ulid(204), 20000),
+    ],
+  },
+  {
+    id: 2, institutionId: 1, campusId: 12, type: 3, name: '充值赠送 10%', status: 1, numberSign: 1,
+    total: 200, signed: 3, signable: true, startAt: rfc3339(BASE_MS - 20 * 86400_000), endAt: rfc3339(BASE_MS + 40 * 86400_000),
+    signeds: [mkSign(2, ulid(203), 1, null, 10000), mkSign(2, ulid(205), 1, null, 15000), mkSign(2, ulid(204), 0, null, 8000)],
+  },
+  {
+    id: 3, institutionId: 1, campusId: 11, type: 4, name: '特殊费用减免', status: 0, numberSign: 1,
+    total: 50, signed: 1, signable: false, startAt: rfc3339(BASE_MS - 60 * 86400_000), endAt: rfc3339(BASE_MS - 5 * 86400_000),
+    signeds: [mkSign(3, ulid(202), 1, null, 5000)],
+  },
+  {
+    id: 4, institutionId: 1, campusId: 13, type: 3, name: '新学期充值有礼', status: 1, numberSign: 1,
+    total: 150, signed: 0, signable: true, startAt: rfc3339(BASE_MS - 5 * 86400_000), endAt: rfc3339(BASE_MS + 35 * 86400_000),
+    signeds: [],
+  },
+  {
+    id: 5, institutionId: 1, campusId: 12, type: 2, name: '老带新推荐(物理)', status: 1, numberSign: 1,
+    total: 80, signed: 2, signable: true, startAt: rfc3339(BASE_MS - 15 * 86400_000), endAt: rfc3339(BASE_MS + 25 * 86400_000),
+    signeds: [mkSign(5, ulid(203), 1, null, 30000), mkSign(5, ulid(205), 1, ulid(203), 30000)],
+  },
+];
+export function queryActivities(f: { type?: number; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Activity[] {
+  return activities.filter((a) => {
+    if (f.type !== undefined && a.type !== f.type) return false;
+    if (f.status !== undefined && a.status !== f.status) return false;
+    if (f.institutionId && f.institutionId !== 1) return false;
+    if (f.campusId && f.campusId !== 0 && a.campusId !== f.campusId) return false;
+    if (f.keyword && !a.name.includes(f.keyword)) return false;
+    return true;
+  });
+}
+export function findActivity(id: number): Activity | undefined {
+  return activities.find((a) => a.id === id);
+}
+
+/* ================= 课程 / 排班 / 打卡 ================= */
+export interface CourseRecord {
+  id: number;
+  courseId: number;
+  teacherId: string;
+  campusId: number;
+  room: string;
+  startTime: string; // RFC3339
+  endTime: string;
+  status: 0 | 1 | 2; // 0 未开始 / 1 进行中 / 2 已结束
+  students: { userId: string; attendance: 0 | 1; payCost: number; giveCost: number }[];
+}
+export interface Course {
+  id: number;
+  institutionId: number;
+  name: string;
+  classify: string;
+  status: 0 | 1;
+  teacherIds: string[];
+  records: CourseRecord[];
+}
+let recordSeq = 6000;
+function mkRecord(courseId: number, teacherId: string, campusId: number, room: string, offsetDays: number, status: 0 | 1 | 2): CourseRecord {
+  const start = BASE_MS + offsetDays * 86400_000;
+  return {
+    id: ++recordSeq,
+    courseId,
+    teacherId,
+    campusId,
+    room,
+    startTime: rfc3339(start),
+    endTime: rfc3339(start + 90 * 60_000),
+    status,
+    students: [
+      { userId: ulid(201), attendance: status === 2 ? 1 : 0, payCost: 10000, giveCost: 0 },
+      { userId: ulid(202), attendance: status === 2 ? 1 : 0, payCost: 10000, giveCost: 0 },
+      { userId: ulid(204), attendance: status === 2 ? 0 : 0, payCost: 10000, giveCost: 0 },
+    ],
+  };
+}
+const courses: Course[] = [
+  {
+    id: 1, institutionId: 1, name: '数学 8 班', classify: '数学', status: 1, teacherIds: [ulid(101)],
+    records: [
+      mkRecord(1, ulid(101), 11, 'A203', -3, 2),
+      mkRecord(1, ulid(101), 11, 'A203', 0, 0),
+      mkRecord(1, ulid(101), 11, 'A205', 2, 0),
+      mkRecord(1, ulid(101), 11, 'A203', 4, 0),
+    ],
+  },
+  {
+    id: 2, institutionId: 1, name: '物理 3 班', classify: '物理', status: 1, teacherIds: [ulid(102)],
+    records: [mkRecord(2, ulid(102), 12, 'B102', -1, 1), mkRecord(2, ulid(102), 12, 'B102', 1, 0)],
+  },
+  {
+    id: 3, institutionId: 1, name: '英语 1 班', classify: '英语', status: 0, teacherIds: [ulid(101)],
+    records: [mkRecord(3, ulid(101), 11, 'C101', -7, 2)],
+  },
+];
+export function queryCourses(f: { classify?: string; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Course[] {
+  return courses.filter((c) => {
+    if (f.classify && c.classify !== f.classify) return false;
+    if (f.status !== undefined && c.status !== f.status) return false;
+    if (f.institutionId && f.institutionId !== 1) return false;
+    if (f.keyword && !c.name.includes(f.keyword)) return false;
+    if (f.campusId && f.campusId !== 0 && !c.records.some((r) => r.campusId === f.campusId)) return false;
+    return true;
+  });
+}
+export function findCourse(id: number): Course | undefined {
+  return courses.find((c) => c.id === id);
+}
+export function findRecord(courseId: number, recordId: number): CourseRecord | undefined {
+  return findCourse(courseId)?.records.find((r) => r.id === recordId);
+}
+
+/* ================= 分账(Nested Set 树) ================= */
+export interface DivideNode {
+  userId: string;
+  realName: string;
+  parentUserId: string | null;
+  quota: number; // 0~1 小数
+  cumulativeIncome: number; // 分
+  children: DivideNode[];
+}
+const divideTree: DivideNode[] = [
+  {
+    userId: ulid(303), realName: '超级管理员', parentUserId: null, quota: 1, cumulativeIncome: 0,
+    children: [
+      {
+        userId: ulid(101), realName: '张婷', parentUserId: ulid(303), quota: 0.3, cumulativeIncome: 1200000,
+        children: [
+          { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), quota: 0.1, cumulativeIncome: 300000, children: [] },
+          { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认)
+        ],
+      },
+      {
+        userId: ulid(102), realName: '李强', parentUserId: ulid(303), quota: 0.2, cumulativeIncome: 800000,
+        children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), quota: 0.15, cumulativeIncome: 200000, children: [] }],
+      },
+    ],
+  },
+];
+export function getDivideTree(): DivideNode[] {
+  return divideTree;
+}
+export function findDivideNode(userId: string): DivideNode | undefined {
+  let found: DivideNode | undefined;
+  const walk = (nodes: DivideNode[]): void => {
+    for (const n of nodes) {
+      if (n.userId === userId) found = n;
+      walk(n.children);
+    }
+  };
+  walk(divideTree);
+  return found;
+}
+
+/* ================= 课酬方案 ================= */
+export interface PayrollPlan {
+  id: number;
+  teacherId: string;
+  schoolId: number;
+  campusId: number;
+  classifyId: number;
+  teacherExp: number;
+  percentage: number;
+  min: number;
+  max: number;
+  reward: number;
+  actived: 0 | 1;
+  effectDate: string;
+}
+const payrollPlans: PayrollPlan[] = [
+  { id: 1, teacherId: ulid(101), schoolId: 1, campusId: 11, classifyId: 1, teacherExp: 8, percentage: 0.5, min: 50000, max: 200000, reward: 10000, actived: 1, effectDate: '2026-09-01' },
+  { id: 2, teacherId: ulid(102), schoolId: 1, campusId: 12, classifyId: 2, teacherExp: 5, percentage: 0.4, min: 40000, max: 150000, reward: 8000, actived: 1, effectDate: '2026-09-01' },
+];
+export function queryPayrollPlans(f: { actived?: number; classifyId?: number }): PayrollPlan[] {
+  return payrollPlans.filter((p) => {
+    if (f.actived !== undefined && p.actived !== f.actived) return false;
+    if (f.classifyId !== undefined && p.classifyId !== f.classifyId) return false;
+    return true;
+  });
+}
+export function findPayrollPlan(id: number): PayrollPlan | undefined {
+  return payrollPlans.find((p) => p.id === id);
+}
+
+/* ================= 收支类型字典 + 收支记录 ================= */
+export interface ExpenditureType {
+  type: number;
+  name: string;
+  direction: 1 | -1; // 1 收 / -1 支
+}
+export const expenditureTypes: ExpenditureType[] = [
+  { type: 101, name: '课程消费(校区)', direction: -1 },
+  { type: 104, name: '课程消费(学校)', direction: -1 },
+  { type: 105, name: '课程消费(学校)', direction: -1 },
+  { type: 201, name: '课程消费(校区)', direction: -1 },
+  { type: 204, name: '课程消费(学校)', direction: -1 },
+  { type: 205, name: '课程消费(学校)', direction: -1 },
+  { type: 300, name: '线下收入', direction: 1 },
+  { type: 301, name: '线上收入', direction: 1 },
+];
+export function expenditureTypeOf(type: number): ExpenditureType | undefined {
+  return expenditureTypes.find((t) => t.type === type);
+}
+
+/* ================= 幂等存储(写端点重复提交 → repeated=true) ================= */
+const idempotent = new Map<string, Record<string, unknown>>();
+export function idempotentKey(method: string, requestId: string): string {
+  return `${method}:${requestId}`;
+}
+export function takeIdempotent(key: string): Record<string, unknown> | undefined {
+  return idempotent.get(key);
+}
+export function setIdempotent(key: string, result: Record<string, unknown>): void {
+  idempotent.set(key, result);
+}
+
+/* ================= 金额/字段便捷校验(端点侧参数校验) ================= */
+export { fmt, toCents };

+ 54 - 0
src/api/mock/envelope.ts

@@ -0,0 +1,54 @@
+/**
+ * envelope.ts —— mock 信封辅助(academic-mock-plan §4 三条硬要求之一)。
+ *
+ * 契约来源:unified/03-migration/academic-frontend-contract-v1.md §1.1 / §1.3(冻结)。
+ * 纪律:
+ *   - 信封恒四字段 {success, code, message, data};success 为兼容字段,新代码判 code;
+ *   - data 无值时**键不出现**(对齐后端 omitempty),不得为 null/undefined 占位;
+ *   - 分页恒 PageData 五字段 {list, total, page, pageSize, pages},空列表 [](非 null);
+ *   - pageSize 超 200 由 mock 侧钳制(对齐后端 ParsePage:默认 10 / 上限 200)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(http.ts 动态 import 门控),不进 dist。
+ */
+import type { PageData, R } from '@/types/api';
+
+/** 成功信封(data 为 undefined 时键不出现,对齐后端 omitempty)。 */
+export function ok<T>(data: T): R<T>;
+/** 成功信封(无返回数据:data 键不出现)。 */
+export function ok(): R<undefined>;
+export function ok<T>(data?: T): R<T | undefined> {
+  const body: Record<string, unknown> = { success: true, code: 200, message: 'ok' };
+  if (data !== undefined) body.data = data;
+  return body as unknown as R<T | undefined>;
+}
+
+/** 失败信封(无 data 键;message 为可直接展示的中文文案)。 */
+export function fail(code: number, message: string): R<undefined> {
+  return { success: false, code, message };
+}
+
+/**
+ * 分页封装(PageData 五字段全量下发)。
+ *
+ * 入参:
+ *   - list 完整数据集(mock 侧已按筛选条件过滤完毕);
+ *   - page 请求页码(缺省 1;<1 按 1 计);
+ *   - size 请求每页条数(缺省 10;**>200 钳制为 200**,对齐后端钳制语义)。
+ *
+ * 出参:
+ *   - R<PageData<T>>:list 切片(越界页 = [],不报错)/ total = 完整集长度 / pages = ceil(total/size),
+ *     size=0 时 pages=0(与后端一致)。
+ */
+export function page<T>(list: T[], pageArg: number, sizeArg: number): R<PageData<T>> {
+  const pageSize = Math.min(Math.max(Math.trunc(sizeArg) || 10, 1), 200);
+  const pageNow = Math.max(Math.trunc(pageArg) || 1, 1);
+  const total = list.length;
+  const start = (pageNow - 1) * pageSize;
+  return ok({
+    list: list.slice(start, start + pageSize),
+    total,
+    page: pageNow,
+    pageSize,
+    pages: pageSize > 0 ? Math.ceil(total / pageSize) : 0,
+  });
+}

+ 49 - 0
src/api/mock/index.ts

@@ -0,0 +1,49 @@
+/**
+ * mock/index.ts —— mock 联调聚合入口(academic-mock-plan §2/§9 M1)。
+ *
+ * 职责单一:聚合全部 mock 路由(7 域文件)→ setRoutes 注入 adapter 路由表 → 给 http 实例挂
+ *   createMockAdapter()。本文件**不**做 env 门控(门控在 http.ts 末尾的动态 import 处),
+ *   以保证生产 build 时 `import.meta.env.DEV=false` → 整块 DCE 消除 → mock 代码不进 dist。
+ *
+ * 覆盖端点(与 academic-frontend-contract-v1.md v1.1 冻结契约对齐):
+ *   - 认证 5:login/issue/me/refresh/logout(守卫链 restoreMe 必需,非 academic 契约范围但联调刚需);
+ *   - 资金 17:端点 1~16 + 21(含 3102/3103/repeated/3120 分支);
+ *   - 活动 16:端点 22~37(含 3105/3106/3107/3108 分支);
+ *   - 分账 6:端点 38~43(含 3111 分支);
+ *   - 课程/排班 13:端点 44~56(含 3121/3122 分支,端点 51 重操作);
+ *   - 课酬/收支 3:端点 58~60(含 3112 分支);
+ *   - 维护 3:端点 61~63(super_admin 门禁)。
+ *   合计 63 条 mock 路由(58 /admin + 5 认证);移动端/公开 5 端点(17 账单/18-20 支付/57 课表)不做。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK=true 下被加载(http.ts 动态 import 门控),不进 dist。
+ */
+import { createMockAdapter, setRoutes } from '@/api/mock/adapter';
+import { authRoutes } from '@/api/mock/routes/auth';
+import { financeRoutes } from '@/api/mock/routes/finance';
+import { activityRoutes } from '@/api/mock/routes/activities';
+import { divideIntoRoutes } from '@/api/mock/routes/divide-into';
+import { courseRoutes } from '@/api/mock/routes/courses';
+import { payrollRoutes } from '@/api/mock/routes/payroll';
+import { maintenanceRoutes } from '@/api/mock/routes/maintenance';
+import type { AxiosInstance } from 'axios';
+
+/**
+ * 挂载 mock adapter(给 http 实例)。
+ *
+ * 入参:
+ *   - http:axios 实例(http.ts 的导出单例)。
+ * 出参:无(副作用:set 路由表 + 替换 http.defaults.adapter 为 mock adapter)。
+ * 幂等:重复调用仅重建路由表与 adapter(dev 场景一次即可)。
+ */
+export function registerMock(http: AxiosInstance): void {
+  setRoutes([
+    ...authRoutes,
+    ...financeRoutes,
+    ...activityRoutes,
+    ...divideIntoRoutes,
+    ...courseRoutes,
+    ...payrollRoutes,
+    ...maintenanceRoutes,
+  ]);
+  http.defaults.adapter = createMockAdapter();
+}

+ 30 - 0
src/api/mock/money.ts

@@ -0,0 +1,30 @@
+/**
+ * money.ts —— mock 金额辅助(academic-mock-plan §4:金额恒字符串两位小数,单位元)。
+ *
+ * 契约来源:academic-frontend-contract-v1.md §1.4(冻结,"最容易出错的一条"):
+ *   - JSON 类型 = 字符串,恒两位小数:"100.00" / "-0.01" / "0.00";
+ *   - 单位 = 元(不是分);可空金额 null → 前端按 "—" 展示。
+ * 纪律:mock 内部**一律以分为整数**运算,输出时格式化——避免 JS 浮点参与(对齐契约
+ * "不要用 JS Number 做加减"的精神;真实前端计算请用 decimal 库或后端算)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载,不进 dist。
+ */
+
+/** 金额字符串 → 分(整数)。入参:string(两位小数契约形态)。出参:number(分);非法输入 = 0。 */
+export function toCents(s: string | null | undefined): number {
+  if (typeof s !== 'string') return 0;
+  const n = Number(s);
+  return Number.isFinite(n) ? Math.round(n * 100) : 0;
+}
+
+/** 分(整数)→ 金额字符串(恒两位小数,可负)。入参:number(分)。出参:string(如 "-12.30")。 */
+export function fmt(cents: number): string {
+  const sign = cents < 0 ? '-' : '';
+  const abs = Math.abs(Math.trunc(cents));
+  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
+}
+
+/** 两位小数字符串格式校验(端点侧参数校验用,命中则 2002)。入参:string。出参:boolean。 */
+export function isMoney(s: unknown): boolean {
+  return typeof s === 'string' && /^\d+\.\d{2}$/.test(s);
+}

+ 296 - 0
src/api/mock/routes/activities.ts

@@ -0,0 +1,296 @@
+/**
+ * routes/activities.ts —— 活动端点 mock(academic 契约 §2.2 端点 22~37,16 个)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 报名交互:活动 status=0(停用)**仍允许报名**(dump 无可用性校验,契约 §2.2 注释)→ mock 不禁用;
+ *   - 分支:3105(已报名该活动)/ 3106(老带新每人限一次)/ 3107(已审核通过不可取消)/ 3108(老带新已产生消费不可取消);
+ *   - 取消报名:activityMoney/activityMoneyUsed 归零(mock 以 signed.status=-1 + money=0 表达,前端刷新整行);
+ *   - 赠送/充值赠送可用额(usable)按校区活动赠送池返回。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok, page } from '@/api/mock/envelope';
+import { toCents, fmt } from '@/api/mock/money';
+import { rfc3339 } from '@/api/mock/time';
+import { inScope, findUser, findActivity, queryActivities } from '@/api/mock/data';
+import type { ActivitySign } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+function checkScope(ctx: MockCtx): R<undefined> | undefined {
+  const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
+  const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
+  return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+/** 报名行 → 列表项。 */
+function signRow(s: ActivitySign): Record<string, unknown> {
+  return {
+    id: s.id,
+    activityId: s.activityId,
+    userId: s.userId,
+    status: s.status,
+    recommendUserId: s.recommendUserId,
+    money: fmt(s.money),
+    createAt: s.createAt,
+  };
+}
+
+export const activityRoutes: MockRoute[] = [
+  /* 22. GET /api/admin/activities —— 活动列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/activities',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const rows = queryActivities({
+        type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
+        status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined,
+        keyword: ctx.query.keyword,
+        institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
+        campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
+      });
+      const mapped = rows.map((a) => ({
+        id: a.id,
+        institutionId: a.institutionId,
+        campusId: a.campusId,
+        type: a.type,
+        name: a.name,
+        status: a.status,
+        numberSign: a.numberSign,
+        total: a.total,
+        signed: a.signeds.filter((s) => s.status === 1).length,
+        signable: a.signable,
+        startAt: a.startAt,
+        endAt: a.endAt,
+      }));
+      return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 23. POST /api/admin/activities —— 新建 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const name = ctx.body?.name as string | undefined;
+      const type = Number(ctx.body?.type ?? 0);
+      if (!name || ![2, 3, 4].includes(type)) return fail(2001, 'name 缺失或 type 非法(2/3/4)');
+      const newId = 100 + Math.floor(Math.random() * 900);
+      return ok({ id: newId, bizNo: `ACT${newId}` });
+    },
+  },
+  /* 24. GET /api/admin/activities/:id —— 详情 */
+  {
+    method: 'GET',
+    path: '/api/admin/activities/:id',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      return ok({ ...a, signeds: a.signeds.map(signRow) });
+    },
+  },
+  /* 25. PUT /api/admin/activities/:id —— 更新(numberSign 不可写) */
+  {
+    method: 'PUT',
+    path: '/api/admin/activities/:id',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      if (ctx.body?.numberSign !== undefined) return fail(3140, 'numberSign 不可修改');
+      a.name = (ctx.body?.name as string) ?? a.name;
+      return ok({ id: a.id });
+    },
+  },
+  /* 26. POST /api/admin/activities/:id/sign —— 报名(3105/3106 分支;status=0 仍允许) */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/:id/sign',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      const userId = ctx.body?.userId as string;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const valid = a.signeds.filter((s) => s.status === 1);
+      // 3105:已报名该活动(存在 status=1 的本人报名)
+      if (valid.some((s) => s.userId === userId)) return fail(3105, '已报名该活动');
+      // 3106:老带新每人限参加一次(type=2 且本人已有任意 status=1 老带新报名)
+      if (a.type === 2 && a.signeds.some((s) => s.userId === userId && s.status === 1)) return fail(3106, '老带新每人限参加一次');
+      a.signeds.push({
+        id: Date.now(),
+        activityId: a.id,
+        userId,
+        status: Number(ctx.body?.status ?? 0),
+        recommendUserId: (ctx.body?.recommendUserId as string) ?? null,
+        money: toCents(String(ctx.body?.money ?? '0.00')),
+        createAt: rfc3339(Date.now()),
+      });
+      return ok({ signedId: a.signeds[a.signeds.length - 1].id });
+    },
+  },
+  /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(3107/3108;归零金额) */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/:id/cancel-sign',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      const userId = ctx.body?.userId as string;
+      if (!userId) return fail(2001, 'userId 缺失');
+      const s = a.signeds.find((x) => x.userId === userId && x.status === 1);
+      if (!s) return fail(3001, '未找到有效报名');
+      // 3107:活动已审核通过(status=1 且已结算过)不可取消
+      if (a.status === 1 && s.status === 1 && a.signed > a.total / 2) return fail(3107, '活动已审核通过,不可取消');
+      // 3108:老带新已产生消费不可取消(type=2 且 money 已核销 = money>0 且已 settle 标记)
+      if (a.type === 2 && s.money > 0) return fail(3108, '老带新已产生消费,不可取消');
+      s.status = -1;
+      s.money = 0; // activityMoney/activityMoneyUsed 归零(契约 §2.2:前端刷新整行)
+      return ok({ cancelled: true });
+    },
+  },
+  /* 28. GET /api/admin/activities/:id/signeds —— 报名列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/activities/:id/signeds',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      let rows = a.signeds;
+      if (ctx.query.status !== undefined) rows = rows.filter((s) => s.status === Number(ctx.query.status));
+      return page(rows.map(signRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/:id/recommend-settle',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      if (ctx.body?.userId === undefined || ctx.body?.orderNo === undefined) return fail(2001, 'userId/orderNo 缺失');
+      return ok({ settled: true, activityId: a.id });
+    },
+  },
+  /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/:id/cancel-recharge',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      if (ctx.body?.userId === undefined || ctx.body?.campusId === undefined || ctx.body?.money === undefined) return fail(2001, '参数缺失');
+      return ok({ cancelled: true });
+    },
+  },
+  /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送 */
+  {
+    method: 'GET',
+    path: '/api/admin/activities/discounts/usable',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.query.userId;
+      if (!userId) return fail(2001, 'userId 缺失');
+      // mock:按用户主校区返回可用赠送池
+      const u = findUser(userId);
+      const campus = u?.campusId ?? 11;
+      return ok({
+        userId,
+        campusId: campus,
+        usable: fmt(32000 + campus * 1000),
+        items: [
+          { activityId: 2, amount: fmt(20000), memo: '充值赠送' },
+          { activityId: 4, amount: fmt(12000 + campus * 1000), memo: '活动赠送' },
+        ],
+      });
+    },
+  },
+  /* 32. POST /api/admin/activities/discounts/use —— 使用赠送 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/discounts/use',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined || ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) {
+        return fail(2001, '参数缺失');
+      }
+      return ok({ used: true });
+    },
+  },
+  /* 33. POST /api/admin/activities/discounts/refund —— 退回赠送 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/discounts/refund',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      if (ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
+      return ok({ refunded: true });
+    },
+  },
+  /* 34. POST /api/admin/activities/discounts/refund-by-activity —— 按活动退回 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/discounts/refund-by-activity',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
+      return ok({ refunded: true });
+    },
+  },
+  /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/recharge-gifts/use',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined || ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) {
+        return fail(2001, '参数缺失');
+      }
+      return ok({ used: true });
+    },
+  },
+  /* 36. POST /api/admin/activities/recharge-gifts/refund —— 退回充值赠送 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/recharge-gifts/refund',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      if (ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
+      return ok({ refunded: true });
+    },
+  },
+  /* 37. POST /api/admin/activities/:id/visits —— 活动到访 */
+  {
+    method: 'POST',
+    path: '/api/admin/activities/:id/visits',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const a = findActivity(Number(ctx.params.id));
+      if (!a) return fail(3001, '活动不存在');
+      if (ctx.body?.activitySignedId === undefined || ctx.body?.memo === undefined) return fail(2001, 'activitySignedId/memo 缺失');
+      return ok({ visited: true, activityId: a.id });
+    },
+  },
+];

+ 239 - 0
src/api/mock/routes/auth.ts

@@ -0,0 +1,239 @@
+/**
+ * routes/auth.ts —— 认证端点 mock(对齐既有 src/api/auth/{login,me}.ts 契约,非 academic 契约范围)。
+ *
+ * 为何 mock 认证:守卫链在业务页会调 restoreMe()→/auth/me 恢复菜单/权限/作用域;
+ *   纯 mock 下若不覆盖 auth,登录后 restoreMe 失败 → 菜单全隐、scope 未就绪 → 卡死。
+ * 纪律:
+ *   - token 用模块级 Map 维护会话(accessToken → {user, roles, institutionId, campusId});
+ *   - 任意有效 token 均可用(不真验签);Authorization 缺失或值为 "mock-invalid-token" → 1001(跳登录);
+ *   - menuTree/permissions 按 role 过滤(超管/教务/财务 = 全树,老师 = 子集),权限码三段式 `域.资源.动作`;
+ *   - 仅 `import type` 引用 DTO(编译期擦除,不与 http.ts 形成运行时循环依赖)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok } from '@/api/mock/envelope';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { MenuNode, R } from '@/types/api';
+import type { LoginUser, MembershipView, TokenPair, LoginStep1 } from '@/api/auth/login';
+
+/* ================= 会话(token → 上下文) ================= */
+interface Session {
+  user: LoginUser;
+  roles: string[];
+  institutionId: number;
+  campusId: number;
+}
+const sessions = new Map<string, Session>();
+let tokenSeq = 0;
+
+/** 判断请求是否携带有效(非 mock 失效)token。入参:auth 头。出参:boolean。 */
+function hasValidToken(ctx: MockCtx): boolean {
+  const t = ctx.auth?.replace(/^Bearer\s+/i, '').trim();
+  return !!t && t !== 'mock-invalid-token' && sessions.has(t);
+}
+
+/** 超管/教务/财务 全量菜单树(PC 端;与原型功能页一一对应)。 */
+const FULL_MENU: MenuNode[] = [
+  { code: 'org.campus.view', label: '机构 / 校区', path: '/org/campuses' },
+  { code: 'org.member.view', label: '成员 / 班级', path: '/org/members' },
+  { code: 'academic.fee.view', label: '费用管理', path: '/academic/fee' },
+  { code: 'academic.schedule.view', label: '排班管理', path: '/academic/schedule' },
+  { code: 'academic.activity.view', label: '活动', path: '/academic/activity' },
+  { code: 'academic.divide.view', label: '分账', path: '/academic/divide' },
+  { code: 'teaching.exam.view', label: '题库 / 组卷', path: '/teaching/exam' },
+  { code: 'teaching.analysis.view', label: '练习分析', path: '/teaching/analysis' },
+  { code: 'system.user.view', label: '用户 / 角色', path: '/system/users' },
+  { code: 'system.task.view', label: '任务监控', path: '/system/tasks' },
+  { code: 'system.log.view', label: '操作日志', path: '/system/logs' },
+];
+
+/** 老师子集(仅排班/课程 + 教学)。 */
+const TEACHER_MENU: MenuNode[] = [
+  { code: 'academic.schedule.view', label: '排班管理', path: '/academic/schedule' },
+  { code: 'teaching.exam.view', label: '题库 / 组卷', path: '/teaching/exam' },
+  { code: 'teaching.analysis.view', label: '练习分析', path: '/teaching/analysis' },
+];
+
+/** 按 role 取菜单树。入参:role。出参:MenuNode[]。 */
+function menuFor(role: string): MenuNode[] {
+  return role === 'teacher' ? TEACHER_MENU : FULL_MENU;
+}
+
+/** 按 role 取权限码集(含 view + 常见 button;mock 从宽,保证 v-perm 按钮可见)。 */
+function permsFor(role: string): string[] {
+  const tree = menuFor(role);
+  const view = tree.map((n) => n.code);
+  // 按钮级:费用/活动/工单等常见动作
+  const buttons = [
+    'academic.fee.recharge', 'academic.fee.refund', 'academic.fee.clear',
+    'academic.fee.transfer', 'academic.fee.approve', 'academic.fee.export',
+    'academic.activity.sign', 'academic.activity.cancel-sign',
+    'academic.schedule.generate', 'academic.schedule.finish',
+    'teaching.exam.create', 'org.member.create', 'system.user.manage',
+  ];
+  return role === 'teacher' ? view.concat(['academic.schedule.finish', 'teaching.exam.create']) : view.concat(buttons);
+}
+
+/** mock 登录用户(超管;memberships 覆盖 3 作用域供选择页演示)。 */
+function mockUser(): LoginUser {
+  return {
+    id: '01J2M' + '303'.padEnd(19, '0').slice(0, 19),
+    username: 'admin',
+    realName: '超级管理员',
+    personType: 'staff',
+    status: 'active',
+  };
+}
+function mockMemberships(): MembershipView[] {
+  return [
+    { membershipId: 1, institutionId: 1, campusId: 11, role: 'academic', status: 'active' },
+    { membershipId: 2, institutionId: 1, campusId: 12, role: 'finance', status: 'active' },
+    { membershipId: 3, institutionId: 1, campusId: 0, role: 'super_admin', status: 'active' },
+  ];
+}
+
+const ACCESS_TTL_MS = 30 * 60 * 1000; // 30 分钟
+
+/** 签发一个 token 并登记会话。入参:institutionId/campusId。出参:{token, role}。 */
+function issueToken(institutionId: number, campusId: number): { token: string; role: string } {
+  tokenSeq += 1;
+  const token = `mock-access-${tokenSeq}`;
+  // 依据选定作用域推 role:匹配 membership;否则默认 super_admin
+  const mems = mockMemberships();
+  const hit = mems.find(
+    (m) => m.institutionId === institutionId && (campusId === 0 ? m.campusId === 0 : m.campusId === campusId),
+  );
+  const role = hit?.role ?? 'super_admin';
+  sessions.set(token, { user: mockUser(), roles: [role], institutionId, campusId });
+  return { token, role };
+}
+
+/** 通用"需有效 token"前置校验(me/refresh/logout 用)。入参:ctx。出参:R 或 undefined(通过)。 */
+function requireAuth(ctx: MockCtx): R<undefined> | undefined {
+  return hasValidToken(ctx) ? undefined : fail(1001, '未登录或登录态已失效');
+}
+
+/**
+ * 角色白名单校验(mock 侧;维护型端点限 super_admin 等场景用)。
+ *
+ * 入参:
+ *   - ctx:mock 上下文(读 Authorization 头定位会话);
+ *   - roles:允许的角色集合(如 ['super_admin'])。
+ * 出参:R 或 undefined(undefined = 通过)。
+ * 分支:
+ *   - token 缺失/失效 → 1001(跳登录);
+ *   - 角色不在白名单 → 1003(无权限,不跳转)。
+ */
+export function requireRole(ctx: MockCtx, roles: string[]): R<undefined> | undefined {
+  const t = ctx.auth?.replace(/^Bearer\s+/i, '').trim();
+  if (!t || t === 'mock-invalid-token') return fail(1001, '未登录或登录态已失效');
+  const s = sessions.get(t);
+  if (!s) return fail(1001, '未登录或登录态已失效');
+  const role = s.roles[0];
+  if (!role || !roles.includes(role)) return fail(1003, `无权限:该操作仅限 ${roles.join('/')} 角色`);
+  return undefined;
+}
+
+export const authRoutes: MockRoute[] = [
+  {
+    method: 'POST',
+    path: '/api/auth/login',
+    handler: (ctx) => {
+      const username = ctx.body?.username as string | undefined;
+      const password = ctx.body?.password as string | undefined;
+      if (!username || !password) return fail(2001, '请输入账号和密码');
+      if (password.length < 6) return fail(2001, '密码长度不足');
+      // mock:任意账密通过,固定超管;返回一次性票据(真实系统由 Redis GETDEL 消费,mock 用内存 token 模拟)
+      const data: LoginStep1 = {
+        loginTicket: `mock-ticket-${Math.random().toString(16).slice(2)}`,
+        user: mockUser(),
+        memberships: mockMemberships(),
+      };
+      return ok(data);
+    },
+  },
+  {
+    method: 'POST',
+    path: '/api/auth/issue',
+    handler: (ctx) => {
+      const ticket = ctx.body?.loginTicket as string | undefined;
+      const institutionId = Number(ctx.body?.institutionId ?? 0);
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      if (!ticket) return fail(2001, '票据缺失');
+      if (institutionId <= 0) return fail(1003, '该作用域不属于当前账号');
+      const { token, role } = issueToken(institutionId, campusId);
+      const data: TokenPair = {
+        accessToken: token,
+        refreshToken: `mock-refresh-${tokenSeq}`,
+        expiresAt: Date.now() + ACCESS_TTL_MS,
+        user: mockUser(),
+        roles: [role],
+        institutionId,
+        campusId,
+        menuTree: menuFor(role),
+        permissions: permsFor(role),
+      };
+      return ok(data);
+    },
+  },
+  {
+    method: 'GET',
+    path: '/api/auth/me',
+    handler: (ctx) => {
+      const denied = requireAuth(ctx);
+      if (denied) return denied;
+      const token = ctx.auth!.replace(/^Bearer\s+/i, '').trim();
+      const s = sessions.get(token)!;
+      const role = s.roles[0] ?? 'super_admin';
+      // MeResult:UserView 扁平 + roles/institutionId/campusId/memberships/menuTree/permissions
+      return ok({
+        ...s.user,
+        roles: s.roles,
+        institutionId: s.institutionId,
+        campusId: s.campusId,
+        memberships: mockMemberships(),
+        menuTree: menuFor(role),
+        permissions: permsFor(role),
+      });
+    },
+  },
+  {
+    method: 'POST',
+    path: '/api/auth/refresh',
+    handler: (ctx) => {
+      // http.ts refresh 单例:body 带 refreshToken;mock 轮换为新 access(保持会话)
+      const refreshToken = ctx.body?.refreshToken as string | undefined;
+      if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) return fail(1001, '刷新失败');
+      // 找到对应会话(refreshToken 与 accessToken 同 seq)
+      const seq = refreshToken.replace('mock-refresh-', '');
+      let role = 'super_admin';
+      let institutionId = 1;
+      let campusId = 0;
+      for (const [t, s] of sessions) {
+        if (t === `mock-access-${seq}`) {
+          role = s.roles[0] ?? 'super_admin';
+          institutionId = s.institutionId;
+          campusId = s.campusId;
+          sessions.delete(t);
+          break;
+        }
+      }
+      tokenSeq += 1;
+      const token = `mock-access-${tokenSeq}`;
+      sessions.set(token, { user: mockUser(), roles: [role], institutionId, campusId });
+      return ok({ accessToken: token, refreshToken: `mock-refresh-${tokenSeq}`, expiresAt: Date.now() + ACCESS_TTL_MS });
+    },
+  },
+  {
+    method: 'POST',
+    path: '/api/auth/logout',
+    handler: (ctx) => {
+      const refreshToken = ctx.body?.refreshToken as string | undefined;
+      if (refreshToken) {
+        const seq = refreshToken.replace('mock-refresh-', '');
+        sessions.delete(`mock-access-${seq}`);
+      }
+      return ok(undefined);
+    },
+  },
+];

+ 326 - 0
src/api/mock/routes/courses.ts

@@ -0,0 +1,326 @@
+/**
+ * routes/courses.ts —— 课程/排班/打卡端点 mock(academic 契约 §2.4 端点 44~56,PC 侧 13 个;端点 57 属移动端不做)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 端点 49 生成课表:同 (courses + start_time) 幂等(已生成不重复);
+ *   - 端点 51 finish 重操作:一次调用产生 扣课时 + 余额变动 + 分账 + 课酬(mock 返回聚合结果 + changed 标记,
+ *     前端须二次确认 + loading 锁 + 失败刷新整页——交互在页面层,mock 只保证契约返回);
+ *   - 端点 52 打卡:逐学生 type/pay/cost/giveCost;
+ *   - 分支:3121(打卡/上课状态非法)/ 3122(排班冲突)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok, page } from '@/api/mock/envelope';
+import { fmt, toCents } from '@/api/mock/money';
+import { inScope, findUser, findCourse, queryCourses, appendLog } from '@/api/mock/data';
+import type { Course, CourseRecord } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+function checkScope(ctx: MockCtx): R<undefined> | undefined {
+  const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
+  const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
+  return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+function courseRow(c: Course): Record<string, unknown> {
+  return {
+    id: c.id,
+    institutionId: c.institutionId,
+    name: c.name,
+    classify: c.classify,
+    status: c.status,
+    teacherIds: c.teacherIds,
+    recordCount: c.records.length,
+  };
+}
+
+export const courseRoutes: MockRoute[] = [
+  /* 44. GET /api/admin/courses —— 课程列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/courses',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const rows = queryCourses({
+        classify: ctx.query.classify,
+        status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined,
+        keyword: ctx.query.keyword,
+        institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
+        campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
+      });
+      return page(rows.map(courseRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 45a. POST /api/admin/courses —— 新建课程 */
+  {
+    method: 'POST',
+    path: '/api/admin/courses',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const name = ctx.body?.name as string | undefined;
+      if (!name) return fail(2001, 'name 缺失');
+      return ok({ id: 100 + Math.floor(Math.random() * 900) });
+    },
+  },
+  /* 45b. PUT /api/admin/courses/:id —— 更新课程 */
+  {
+    method: 'PUT',
+    path: '/api/admin/courses/:id',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      return ok({ id: c.id });
+    },
+  },
+  /* 46. GET /api/admin/courses/:id/classes —— 排班规则列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/courses/:id/classes',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      // mock:每课程 2 条排班规则
+      const rules = c.records.slice(0, 2).map((r, i) => ({
+        classId: r.id * 10 + i,
+        courseId: c.id,
+        teacherId: r.teacherId,
+        week: i + 1,
+        start: r.startTime,
+        end: r.endTime,
+        campusId: r.campusId,
+        room: r.room,
+      }));
+      return page(rules, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 47. POST /api/admin/courses/:id/classes —— 新建排班规则(3122 冲突) */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/:id/classes',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      if (ctx.body?.teacher === undefined || ctx.body?.week === undefined) return fail(2001, 'teacher/week 缺失');
+      // 3122:同老师同周同校区已有规则 → 排班冲突
+      const exists = c.records.some((r) => r.teacherId === (ctx.body?.teacher as string) && r.campusId === Number(ctx.body?.campusId ?? 0));
+      if (exists) return fail(3122, '排班冲突:该老师在此校区已存在排班');
+      return ok({ classId: 9000 + c.id });
+    },
+  },
+  /* 48. GET /api/admin/courses/:id/records —— 课表(排班实例) */
+  {
+    method: 'GET',
+    path: '/api/admin/courses/:id/records',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      const rows = c.records.map((r) => ({
+        recordId: r.id,
+        courseId: c.id,
+        teacherId: r.teacherId,
+        campusId: r.campusId,
+        room: r.room,
+        startTime: r.startTime,
+        endTime: r.endTime,
+        status: r.status,
+        studentCount: r.students.length,
+      }));
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 49. POST /api/admin/courses/:id/records/generate —— 生成课表(幂等) */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/:id/records/generate',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      const days = Number(ctx.body?.days ?? 14);
+      // 幂等:已生成过同课程未来课表 → 不重复
+      const generated = c.records.filter((r) => r.status === 0).length;
+      return ok({ generated, days, changed: generated < days });
+    },
+  },
+  /* 50. POST /api/admin/courses/records/:recordId/start —— 开始上课 */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/records/:recordId/start',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      // 找 record(遍历课程)
+      let rec: CourseRecord | undefined;
+      let courseId = 0;
+      for (const c of queryCourses({})) {
+        const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
+        if (r) {
+          rec = r;
+          courseId = c.id;
+          break;
+        }
+      }
+      if (!rec) return fail(3001, '排班实例不存在');
+      if (rec.status !== 0) return fail(3121, '上课状态非法(仅未开始可开始)');
+      rec.status = 1;
+      return ok({ recordId: rec.id, status: 1, courseId });
+    },
+  },
+  /* 51. POST /api/admin/courses/records/:recordId/finish —— 结束上课(重操作:扣课时+spend+分账+课酬) */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/records/:recordId/finish',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      let rec: CourseRecord | undefined;
+      let courseId = 0;
+      for (const c of queryCourses({})) {
+        const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
+        if (r) {
+          rec = r;
+          courseId = c.id;
+          break;
+        }
+      }
+      if (!rec) return fail(3001, '排班实例不存在');
+      if (rec.status === 2) return fail(3121, '上课状态非法(已结束不可重复结束)');
+      rec.status = 2;
+      // 逐学生:出勤者扣课时(spend 101 −),产生分账 + 课酬
+      let spentTotal = 0;
+      const perStudent = rec.students.map((s) => {
+        if (s.attendance === 1) {
+          appendLog(s.userId, rec.campusId, 101, -s.payCost, '课时代扣(finish)');
+          spentTotal += s.payCost;
+        }
+        return {
+          userId: s.userId,
+          attendance: s.attendance,
+          payCost: fmt(s.payCost),
+          giveCost: fmt(s.giveCost),
+          counteracted: false,
+        };
+      });
+      return ok({
+        recordId: rec.id,
+        courseId,
+        status: 2,
+        changed: true,
+        totalSpend: fmt(spentTotal),
+        divide: [{ level: 1, quota: '0.30', amount: fmt(Math.round(spentTotal * 0.3)) }],
+        payroll: { detailId: rec.id * 10, reward: fmt(0) },
+        perStudent,
+      });
+    },
+  },
+  /* 52. POST /api/admin/courses/records/:recordId/attendance —— 打卡(逐学生) */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/records/:recordId/attendance',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      let rec: CourseRecord | undefined;
+      for (const c of queryCourses({})) {
+        const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
+        if (r) rec = r;
+      }
+      if (!rec) return fail(3001, '排班实例不存在');
+      if (rec.status === 2) return fail(3121, '已结束不可再打卡');
+      const list = (ctx.body?.list as Array<{ userId: string; attendance: number; payCost?: string; giveCost?: string }>) ?? [];
+      for (const item of list) {
+        const s = rec.students.find((x) => x.userId === item.userId);
+        if (s) {
+          s.attendance = item.attendance === 1 ? 1 : 0;
+          if (item.payCost) s.payCost = toCents(item.payCost);
+          if (item.giveCost) s.giveCost = toCents(item.giveCost);
+        }
+      }
+      return ok({ recordId: rec.id, checked: list.length });
+    },
+  },
+  /* 53. GET /api/admin/courses/:id/students —— 报课学生(含欠费) */
+  {
+    method: 'GET',
+    path: '/api/admin/courses/:id/students',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      const rec = c.records[0];
+      const rows = (rec?.students ?? []).map((s) => {
+        const u = findUser(s.userId);
+        return {
+          userId: s.userId,
+          realName: u?.realName ?? '—',
+          payCost: fmt(s.payCost),
+          giveCost: fmt(s.giveCost),
+          unpaid: fmt(20000), // mock 欠费
+          status: '在读',
+        };
+      });
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 54. POST /api/admin/courses/:id/students —— 报名/补报名 */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/:id/students',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const c = findCourse(Number(ctx.params.id));
+      if (!c) return fail(3001, '课程不存在');
+      const userId = ctx.body?.userId as string;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const rec = c.records[0];
+      if (rec && !rec.students.some((s) => s.userId === userId)) {
+        rec.students.push({ userId, attendance: 0, payCost: toCents(String(ctx.body?.payCost ?? '0.00')), giveCost: 0 });
+      }
+      return ok({ enrolled: true, courseId: c.id, userId });
+    },
+  },
+  /* 55. GET /api/admin/courses/arrears —— 欠费列表(unpaid>0) */
+  {
+    method: 'GET',
+    path: '/api/admin/courses/arrears',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const rows = [
+        { studentId: '01J2M' + '201'.padEnd(19, '0').slice(0, 19), courseId: 1, unpaid: fmt(20000), counteracted: false },
+        { studentId: '01J2M' + '202'.padEnd(19, '0').slice(0, 19), courseId: 1, unpaid: fmt(12000), counteracted: false },
+        { studentId: '01J2M' + '204'.padEnd(19, '0').slice(0, 19), courseId: 2, unpaid: fmt(8000), counteracted: true },
+      ];
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 56. POST /api/admin/courses/arrears/counteract —— 欠费核销 */
+  {
+    method: 'POST',
+    path: '/api/admin/courses/arrears/counteract',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const studentId = ctx.body?.studentId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = toCents(String(ctx.body?.money ?? '0.00'));
+      if (!studentId || m <= 0) return fail(2001, 'studentId/money 缺失或非法');
+      appendLog(studentId, campusId, 207, -m, '欠费核销');
+      return ok({ counteracted: true, studentId, amount: fmt(m) });
+    },
+  },
+];

+ 214 - 0
src/api/mock/routes/divide-into.ts

@@ -0,0 +1,214 @@
+/**
+ * routes/divide-into.ts —— 分账端点 mock(academic 契约 §2.3 端点 38~43,6 个)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 端点 38 树查询:`rootUserId?` → Nested Set 树(含 quota / 累计收入;金额恒字符串两位小数,quota 为 0~1 小数比);
+ *   - 端点 39 添加下线:`parentUserId,subUserId` → {ancestorIds:[]};**新节点默认 quota=0.00**(契约 §2.3 注释:
+ *     建完下线须显式调端点 40 设置额度,否则该链不产生分成且不报错);
+ *   - 端点 40 设额度:`quota` → {affectedUserIds:[]}(含该节点及其全部下线,额度变化影响子链分成);
+ *   - 端点 41/42/43 分账流水:建(findUserId,studentId,orderNo,remark?)/ 删(studentId,orderNo)/ 查(分页 + 时间范围);
+ *   - 分支:3111(分账层级超限 / 额度超上限)/ 2001(缺参)/ 3001(节点/用户不存在)/ 1004(越界)。
+ *
+ * 数据源:分账树来自 data.ts(getDivideTree / findDivideNode,可变引用,39 直接挂新节点);
+ *   分账流水为模块级单例数组(dev 会话内持久,刷新即重置,与 mock 定位一致)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok, page } from '@/api/mock/envelope';
+import { fmt } from '@/api/mock/money';
+import { rfc3339 } from '@/api/mock/time';
+import { inScope, findUser, getDivideTree, findDivideNode } from '@/api/mock/data';
+import type { DivideNode } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+/** 分账层级上限(契约 §6 数据策略:超管 → 校区主任 → 老师,3 层;第 4 层即超限 → 3111)。 */
+const MAX_DIVIDE_DEPTH = 3;
+
+/** 分账流水(模块级单例;dev 会话内可变)。 */
+interface DivideLog {
+  id: number;
+  findUserId: string;
+  studentId: string;
+  orderNo: string;
+  money: number; // 分
+  remark: string;
+  createAt: string;
+}
+let logSeq = 7000;
+const logs: DivideLog[] = [
+  { id: ++logSeq, findUserId: '01J2M1010000000000000000', studentId: '01J2M201000000000000000', orderNo: 'ORD20260901001', money: 30000, remark: '数学课时分成', createAt: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
+  { id: ++logSeq, findUserId: '01J2M1020000000000000000', studentId: '01J2M203000000000000000', orderNo: 'ORD20260901002', money: 20000, remark: '物理课时分成', createAt: rfc3339(Date.parse('2026-11-02T10:30:00+08:00')) },
+];
+
+function checkScope(ctx: MockCtx): R<undefined> | undefined {
+  const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
+  const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
+  return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+/** 树节点 → 输出行(quota 两位小数字符串、累计收入金额字符串;children 递归)。入参:node。出参:对象。 */
+function nodeRow(n: DivideNode): Record<string, unknown> {
+  return {
+    userId: n.userId,
+    realName: n.realName,
+    parentUserId: n.parentUserId,
+    quota: n.quota.toFixed(2),
+    cumulativeIncome: fmt(n.cumulativeIncome),
+    children: n.children.map(nodeRow),
+  };
+}
+
+/** 求某用户在树中的深度(root=1;未找到 = 0)。入参:userId。出参:number。 */
+function depthOf(userId: string): number {
+  let depth = 0;
+  const walk = (nodes: DivideNode[], d: number): boolean => {
+    for (const n of nodes) {
+      if (n.userId === userId) {
+        depth = d;
+        return true;
+      }
+      if (walk(n.children, d + 1)) return true;
+    }
+    return false;
+  };
+  walk(getDivideTree(), 1);
+  return depth;
+}
+
+/** 收集节点自身 + 全部下线的 userId(额度变化影响子链分成)。入参:node。出参:string[]。 */
+function subtreeUserIds(n: DivideNode): string[] {
+  return [n.userId, ...n.children.flatMap(subtreeUserIds)];
+}
+
+export const divideIntoRoutes: MockRoute[] = [
+  /* 38. GET /api/admin/divide-into/tree —— Nested Set 树(rootUserId? 定位根;缺省返回全部根) */
+  {
+    method: 'GET',
+    path: '/api/admin/divide-into/tree',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const roots = getDivideTree();
+      const rootUserId = ctx.query.rootUserId;
+      if (rootUserId) {
+        const root = findDivideNode(rootUserId);
+        if (!root) return fail(3001, '分账节点不存在');
+        return ok({ tree: [nodeRow(root)] });
+      }
+      return ok({ tree: roots.map(nodeRow) });
+    },
+  },
+  /* 39. POST /api/admin/divide-into/members —— 添加下线(新节点 quota=0.00;层级超限 3111) */
+  {
+    method: 'POST',
+    path: '/api/admin/divide-into/members',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const parentUserId = ctx.body?.parentUserId as string | undefined;
+      const subUserId = ctx.body?.subUserId as string | undefined;
+      if (!parentUserId || !subUserId) return fail(2001, 'parentUserId/subUserId 缺失');
+      const parent = findDivideNode(parentUserId);
+      if (!parent) return fail(3001, '上级分账节点不存在');
+      if (!findUser(subUserId)) return fail(3001, '下线用户不存在');
+      if (findDivideNode(subUserId)) return fail(3140, '该用户已在分账树中,不可重复添加');
+      // 3111:层级超限(上级已是第 3 层,再加一层即第 4 层)
+      if (depthOf(parentUserId) >= MAX_DIVIDE_DEPTH) return fail(3111, '分账层级超限(最多 3 层)');
+      // 挂新节点:quota 默认 0(契约 §2.3:不设置额度则该链不分成)
+      parent.children.push({ userId: subUserId, realName: findUser(subUserId)!.realName, parentUserId, quota: 0, cumulativeIncome: 0, children: [] });
+      return ok({ ancestorIds: [] });
+    },
+  },
+  /* 40. PUT /api/admin/divide-into/members/:userId/quota —— 设额度(>1 超限 3111;影响子链) */
+  {
+    method: 'PUT',
+    path: '/api/admin/divide-into/members/:userId/quota',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const node = findDivideNode(ctx.params.userId);
+      if (!node) return fail(3001, '分账节点不存在');
+      const raw = ctx.body?.quota;
+      const quota = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
+      if (!Number.isFinite(quota)) return fail(2002, 'quota 格式错误(0~1 小数,如 "0.30")');
+      // 3111:额度超上限(>1 即 100% 以上,非法)
+      if (quota < 0 || quota > 1) return fail(3111, '分账额度超上限(quota 须 0~1)');
+      node.quota = quota;
+      return ok({ affectedUserIds: subtreeUserIds(node) });
+    },
+  },
+  /* 41. POST /api/admin/divide-into/logs —— 记分账流水 {logId} */
+  {
+    method: 'POST',
+    path: '/api/admin/divide-into/logs',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const findUserId = ctx.body?.findUserId as string | undefined;
+      const studentId = ctx.body?.studentId as string | undefined;
+      const orderNo = ctx.body?.orderNo as string | undefined;
+      if (!findUserId || !studentId || !orderNo) return fail(2001, 'findUserId/studentId/orderNo 缺失');
+      if (!findDivideNode(findUserId)) return fail(3001, '分账人不在分账树中');
+      if (!findUser(studentId)) return fail(3001, '学生不存在');
+      const log: DivideLog = {
+        id: ++logSeq,
+        findUserId,
+        studentId,
+        orderNo,
+        money: 0,
+        remark: String(ctx.body?.remark ?? ''),
+        createAt: rfc3339(Date.now()),
+      };
+      logs.unshift(log);
+      return ok({ logId: log.id });
+    },
+  },
+  /* 42. DELETE /api/admin/divide-into/logs —— 删分账流水(studentId,orderNo) */
+  {
+    method: 'DELETE',
+    path: '/api/admin/divide-into/logs',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const studentId = ctx.query.studentId;
+      const orderNo = ctx.query.orderNo;
+      if (!studentId || !orderNo) return fail(2001, 'studentId/orderNo 缺失');
+      const idx = logs.findIndex((l) => l.studentId === studentId && l.orderNo === orderNo);
+      if (idx < 0) return fail(3001, '分账流水不存在');
+      logs.splice(idx, 1);
+      return ok({ deleted: true });
+    },
+  },
+  /* 43. GET /api/admin/divide-into/logs —— 分账流水列表(userId?/studentId?/时间范围,分页) */
+  {
+    method: 'GET',
+    path: '/api/admin/divide-into/logs',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const from = ctx.query.from ? Date.parse(`${ctx.query.from}T00:00:00+08:00`) : null;
+      const to = ctx.query.to ? Date.parse(`${ctx.query.to}T23:59:59.999+08:00`) : null;
+      const rows = logs
+        .filter((l) => {
+          if (ctx.query.userId && l.findUserId !== ctx.query.userId) return false;
+          if (ctx.query.studentId && l.studentId !== ctx.query.studentId) return false;
+          const t = Date.parse(l.createAt);
+          if (from !== null && t < from) return false;
+          if (to !== null && t > to) return false;
+          return true;
+        })
+        .sort((a, b) => (a.createAt < b.createAt ? 1 : a.createAt > b.createAt ? -1 : b.id - a.id));
+      const mapped = rows.map((l) => ({
+        id: l.id,
+        findUserId: l.findUserId,
+        studentId: l.studentId,
+        orderNo: l.orderNo,
+        money: fmt(l.money),
+        remark: l.remark,
+        createAt: l.createAt,
+      }));
+      return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+];

+ 421 - 0
src/api/mock/routes/finance.ts

@@ -0,0 +1,421 @@
+/**
+ * routes/finance.ts —— 资金端点 mock(academic 契约 §2.1 端点 1~16, 21,PC 侧 17 个)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 金额:入参/出参恒**字符串两位小数**(元);mock 内部以**分**整数运算(money.ts);
+ *   - 关键 DTO:§3.1 CustomRefundResult(端点 6)/ §3.2 ClearBalanceResult(端点 9);
+ *   - 分支:3102(余额 0 不可退)/ 3103(warn,code 仍 200 + warnings[]+badDebt)/ 3104(校区不存在)/
+ *     3120(清零前置不满足)/ 2001(缺参)/ 2002(金额格式错)/ 1004(越界)/ repeated=true(幂等回放)。
+ *   - 流水 type 编码 = §10.2(100 充值 + / 207 退费 − / 208 清零 − / 106,206 转结 / 108 坏账 +)。
+ *   - 端点 51 类重操作不在本文件(属 courses.ts 端点 51,含二次确认交互,前端页面处理)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok, page } from '@/api/mock/envelope';
+import { isMoney, toCents, fmt } from '@/api/mock/money';
+import { inScope, findUser, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
+import type { ExpenseLog } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+/* ================= 通用前置校验 ================= */
+/** 作用域越界校验:query 带 institutionId/campusId 且不在合法集合 → 1004。入参:ctx。出参:R|undefined(undefined=通过)。 */
+function checkScope(ctx: MockCtx): R<undefined> | undefined {
+  const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
+  const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
+  return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+/** 金额入参校验(两位小数字符串 + >0)。入参:s。出参:{cents} 或 {err}。 */
+function moneyParam(s: unknown): { cents: number } | { err: R<undefined> } {
+  if (typeof s !== 'string' || !isMoney(s)) return { err: fail(2002, '金额格式错误(须两位小数,如 "100.00")') };
+  const cents = toCents(s);
+  if (cents <= 0) return { err: fail(2002, '金额须大于 0') };
+  return { cents };
+}
+
+/** 幂等包装:同 requestId 重复 → 回放首次结果并置 repeated=true。入参:key/首次构造器。出参:R。 */
+function withIdempotent(key: string, build: () => { data: Record<string, unknown>; repeated?: boolean }): R<unknown> {
+  const prev = takeIdempotent(key);
+  if (prev) return ok({ ...prev, repeated: true });
+  const first = build();
+  setIdempotent(key, first.data);
+  return ok({ ...first.data, repeated: first.repeated ?? false });
+}
+
+/** 流水行 → 列表项(金额/余额字符串化)。入参:log。出参:对象。 */
+function logRow(l: ExpenseLog): Record<string, unknown> {
+  return {
+    id: l.id,
+    userId: l.userId,
+    institutionId: l.institutionId,
+    campusId: l.campusId,
+    type: l.type,
+    money: fmt(l.money),
+    balanceAfter: fmt(l.balanceAfter),
+    createAt: l.createAt,
+    remark: l.remark,
+  };
+}
+
+export const financeRoutes: MockRoute[] = [
+  /* 1. GET /api/admin/finance/accounts/:userId —— 全局+校区账户快照 */
+  {
+    method: 'GET',
+    path: '/api/admin/finance/accounts/:userId',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const u = findUser(ctx.params.userId);
+      if (!u) return fail(3001, '用户不存在');
+      const campusId = ctx.query.campusId ? Number(ctx.query.campusId) : u.campusId;
+      const acct = getAccount(ctx.params.userId, campusId);
+      return ok({
+        userId: ctx.params.userId,
+        institutionId: 1,
+        campusId,
+        totalBalance: fmt(getGlobalBalance(ctx.params.userId, campusId)),
+        campusBalance: fmt(acct.campus),
+        specialExpenses: fmt(0),
+        cumulativeIncome: fmt(0),
+      });
+    },
+  },
+  /* 2. GET /api/admin/finance/accounts/:userId/refundable —— {refundableAmount} */
+  {
+    method: 'GET',
+    path: '/api/admin/finance/accounts/:userId/refundable',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const u = findUser(ctx.params.userId);
+      if (!u) return fail(3001, '用户不存在');
+      const campusId = ctx.query.campusId ? Number(ctx.query.campusId) : u.campusId;
+      const refundable = getAccount(ctx.params.userId, campusId).campus;
+      return ok({ refundableAmount: fmt(Math.max(refundable, 0)) });
+    },
+  },
+  /* 3. POST /api/admin/finance/recharges —— 充值 {logId,balance,repeated} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/recharges',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const key = idempotentKey('recharge', String(ctx.body?.requestId ?? ''));
+      return withIdempotent(key, () => {
+        const log = appendLog(userId, campusId, 100, m.cents, '充值');
+        return { data: { logId: log.id, balance: fmt(getAccount(userId, campusId).total), repeated: false } };
+      });
+    },
+  },
+  /* 4. POST /api/admin/finance/expenditures —— 消费 {logId,balance} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/expenditures',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const type = Number(ctx.body?.type ?? 101);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const log = appendLog(userId, campusId, type, -m.cents, '消费');
+      return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total) });
+    },
+  },
+  /* 5. POST /api/admin/finance/refunds —— 退费 {logId,balance,changed} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/refunds',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const before = getAccount(userId, campusId).campus;
+      if (before <= 0) return fail(3102, '账户余额为 0,不可退费');
+      const changed = m.cents > before;
+      const log = appendLog(userId, campusId, 207, -Math.min(m.cents, before), '退费');
+      return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total), changed });
+    },
+  },
+  /* 6. POST /api/admin/finance/refunds/custom —— CustomRefundResult(§3.1,核心分支) */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/refunds/custom',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      if (ctx.body?.requestId === undefined || ctx.body?.requestId === '') return fail(2001, 'requestId 必填');
+      // 3104:校区不存在
+      if (!Number.isInteger(campusId) || campusId <= 0 || ![11, 12, 13].includes(campusId)) return fail(3104, '校区不存在或未归属机构');
+
+      const acct = getAccount(userId, campusId);
+      const refundable = Math.max(acct.campus, 0);
+      // 3102:校区余额 ≤ 0 不可退费(无 data)
+      if (refundable <= 0) return fail(3102, '账户余额为 0,不可退费');
+      const key = idempotentKey('custom-refund', String(ctx.body?.requestId));
+      return withIdempotent(key, () => {
+        const total = getGlobalBalance(userId, campusId);
+        const requested = m.cents;
+        const badDebt = Math.max(requested - refundable, 0);
+        const refunded = requested - badDebt;
+        const log207 = appendLog(userId, campusId, 207, -requested, '余额退费'); // 恒全额(契约 §3.1:207 恒 -requested)
+        const log108 = badDebt > 0 ? appendLog(userId, campusId, 108, badDebt, '坏账处理') : null;
+        const warnings = badDebt > 0 ? [{ code: 3103, message: `可退金额不足,差额 ${fmt(badDebt)} 已计入坏账` }] : [];
+        const data = {
+          requested: fmt(requested),
+          refunded: fmt(refunded),
+          badDebt: fmt(badDebt),
+          refundableAmount: fmt(refundable),
+          balance: fmt(acct.campus - refunded),
+          totalBalance: fmt(total - refunded),
+          totalIncome: fmt(0),
+          specialExpenses: fmt(badDebt),
+          logId207: log207.id,
+          logId108: log108?.id ?? null,
+          expiredActivityIds: [] as number[],
+          warnings,
+        };
+        return { data, repeated: false };
+      });
+    },
+  },
+  /* 7. POST /api/admin/finance/transfers —— 转结 {logId106,logId206} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/transfers',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const fromCampus = Number(ctx.body?.fromCampusId ?? 0);
+      const toCampus = Number(ctx.body?.toCampusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId) || fromCampus === 0 || toCampus === 0) return fail(2001, '参数缺失');
+      // dump 口径:106 转结出 = +money,206 转结入 = −money(★以 dump 为准)
+      const log106 = appendLog(userId, fromCampus, 106, m.cents, '转结出');
+      const log206 = appendLog(userId, toCampus, 206, -m.cents, '转结入');
+      return ok({ logId106: log106.id, logId206: log206.id });
+    },
+  },
+  /* 8. POST /api/admin/finance/bad-debts —— 坏账 {logId,specialExpenses} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/bad-debts',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const key = idempotentKey('bad-debt', String(ctx.body?.requestId ?? ''));
+      return withIdempotent(key, () => {
+        const log = appendLog(userId, campusId, 108, m.cents, '坏账处理');
+        return { data: { logId: log.id, specialExpenses: fmt(m.cents), repeated: false } };
+      });
+    },
+  },
+  /* 9. POST /api/admin/finance/cleared-balances —— ClearBalanceResult(§3.2,3120 前置) */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/cleared-balances',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const acct = getAccount(userId, campusId);
+      const total = getGlobalBalance(userId, campusId);
+      // 3120 前置:余额≤0 或 校区余额>全局余额
+      if (acct.campus <= 0 || acct.campus > total) return fail(3120, '余额清零前置条件不满足(余额≤0 或 校区余额>全局余额)');
+      const key = idempotentKey('clear', `${userId}:${campusId}`);
+      return withIdempotent(key, () => {
+        const log = appendLog(userId, campusId, 208, -acct.campus, '余额清零');
+        const data = {
+          cleared: fmt(acct.campus),
+          campusBalanceBefore: fmt(acct.campus),
+          totalBalanceBefore: fmt(total),
+          balance: fmt(0),
+          totalBalance: fmt(total - acct.campus),
+          specialExpenses: fmt(acct.campus),
+          logId208: log.id,
+          expiredActivityIds: [] as number[],
+        };
+        return { data, repeated: false };
+      });
+    },
+  },
+  /* 10. POST /api/admin/finance/spends —— spend {spend} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/spends',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.spendMoney);
+      if ('err' in m) return m.err;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const log = appendLog(userId, campusId, 101, -m.cents, 'spend 扣减');
+      return ok({ spend: fmt(m.cents), logId: log.id });
+    },
+  },
+  /* 11. GET /api/admin/finance/logs —— 分页流水(倒序;分页边界主战场) */
+  {
+    method: 'GET',
+    path: '/api/admin/finance/logs',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const rows = queryLogs({
+        userId: ctx.query.userId,
+        type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
+        from: ctx.query.from,
+        to: ctx.query.to,
+        institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
+        campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
+      });
+      return page(rows.map(logRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 12. GET /api/admin/finance/requests —— 工单列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/finance/requests',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const rows = queryRequests({
+        type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
+        approved: ctx.query.approved !== undefined ? Number(ctx.query.approved) : undefined,
+        userId: ctx.query.userId,
+        institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
+        campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
+      });
+      const mapped = rows.map((r) => ({
+        id: r.id,
+        bizNo: r.bizNo,
+        type: r.type,
+        userId: r.userId,
+        institutionId: r.institutionId,
+        campusId: r.campusId,
+        money: fmt(r.money),
+        approved: r.approved,
+        applicantId: r.applicantId,
+        approverId: r.approverId,
+        remark: r.remark,
+        createAt: r.createAt,
+      }));
+      return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 13. POST /api/admin/finance/requests —— 建工单 {id,bizNo} */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/requests',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const type = Number(ctx.body?.type ?? 0);
+      const userId = ctx.body?.userId as string;
+      const campusId = Number(ctx.body?.campusId ?? 0);
+      const m = moneyParam(ctx.body?.money);
+      if ('err' in m) return m.err;
+      if (!type || !userId || !findUser(userId)) return fail(2001, 'type/userId 缺失或非法');
+      const r = createRequest(type, userId, campusId, m.cents, String(ctx.body?.remark ?? ''), userId);
+      return ok({ id: r.id, bizNo: r.bizNo });
+    },
+  },
+  /* 14. POST /api/admin/finance/requests/:id/approve —— 审批通过 */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/requests/:id/approve',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const r = findRequest(Number(ctx.params.id));
+      if (!r) return fail(3001, '工单不存在');
+      if (r.approved !== 0) return fail(3140, '工单已处理,不可重复审批');
+      r.approved = 1;
+      r.approverId = '01J2M' + '302'.padEnd(19, '0').slice(0, 19);
+      return ok({ approved: 1, result: { logId: r.id * 100, balance: fmt(0) } });
+    },
+  },
+  /* 15. POST /api/admin/finance/requests/:id/reject —— 驳回 */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/requests/:id/reject',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const r = findRequest(Number(ctx.params.id));
+      if (!r) return fail(3001, '工单不存在');
+      if (r.approved !== 0) return fail(3140, '工单已处理,不可重复操作');
+      r.approved = -1;
+      r.approverId = '01J2M' + '302'.padEnd(19, '0').slice(0, 19);
+      return ok({ approved: -1 });
+    },
+  },
+  /* 16. POST /api/admin/finance/requests/:id/cancel —— 撤销(仅 approved=0) */
+  {
+    method: 'POST',
+    path: '/api/admin/finance/requests/:id/cancel',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const r = findRequest(Number(ctx.params.id));
+      if (!r) return fail(3001, '工单不存在');
+      if (r.approved !== 0) return fail(3140, '仅待审工单可撤销');
+      r.approved = -2;
+      return ok({ approved: -2 });
+    },
+  },
+  /* 21. GET /api/admin/finance/dashboard —— 期间收支/余额分布(P2,简单聚合) */
+  {
+    method: 'GET',
+    path: '/api/admin/finance/dashboard',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const logs = queryLogs({ from: ctx.query.from, to: ctx.query.to });
+      let income = 0;
+      let expense = 0;
+      for (const l of logs) {
+        if (l.money > 0) income += l.money;
+        else expense += -l.money;
+      }
+      return ok({
+        periodIncome: fmt(income),
+        periodExpense: fmt(expense),
+        net: fmt(income - expense),
+        balanceDistribution: [
+          { campusId: 11, balance: fmt(getGlobalBalance('01J2M' + '201'.padEnd(19, '0').slice(0, 19), 11)) },
+          { campusId: 12, balance: fmt(getGlobalBalance('01J2M' + '203'.padEnd(19, '0').slice(0, 19), 12)) },
+          { campusId: 13, balance: fmt(getGlobalBalance('01J2M' + '205'.padEnd(19, '0').slice(0, 19), 13)) },
+        ],
+      });
+    },
+  },
+];

+ 61 - 0
src/api/mock/routes/maintenance.ts

@@ -0,0 +1,61 @@
+/**
+ * routes/maintenance.ts —— 系统维护端点 mock(academic 契约 §2.5 端点 61~63,3 个)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 61~63 为**维护型**数据修复端点,角色**仅 super_admin**(契约 §2.5 角色列)→ mock 侧用 requireRole
+ *     校验(非 super_admin → 1003 无权限;token 失效 → 1001);
+ *   - 均为**可重跑**的数据修复(幂等语义:重复执行不报错,仅返回受影响条数);
+ *   - 63 activity-sync 须带 `mode=used-log|signed`(二选一,缺省/非法 → 2001)。
+ *
+ * 前端约定(契约 §2.5 注释):维护型端点入口应放"系统维护"页并加二次确认,不放在日常操作区——
+ *   该交互在页面层,mock 只保证角色校验 + 参数校验 + 返回形态。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok } from '@/api/mock/envelope';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import { requireRole } from '@/api/mock/routes/auth';
+
+/** super_admin 门禁(维护型端点专用)。入参:ctx。出参:R|undefined(undefined=通过)。 */
+function requireSuper(ctx: MockCtx) {
+  return requireRole(ctx, ['super_admin']);
+}
+
+export const maintenanceRoutes: MockRoute[] = [
+  /* 61. POST /api/admin/maintenance/courses-max-people —— 课程人数上限数据修复(可重跑) */
+  {
+    method: 'POST',
+    path: '/api/admin/maintenance/courses-max-people',
+    handler: (ctx) => {
+      const denied = requireSuper(ctx);
+      if (denied) return denied;
+      // mock:修复 3 门课程的 maxPeople(数据源 data.ts 3 课程)
+      return ok({ affected: 3, message: '已修复 3 门课程的 maxPeople 字段' });
+    },
+  },
+  /* 62. POST /api/admin/maintenance/courses-campus —— 课程-校区关联数据修复(可重跑) */
+  {
+    method: 'POST',
+    path: '/api/admin/maintenance/courses-campus',
+    handler: (ctx) => {
+      const denied = requireSuper(ctx);
+      if (denied) return denied;
+      // mock:修复 2 门课程的校区关联(courseId 与 campusId 对齐)
+      return ok({ affected: 2, message: '已修复 2 门课程的 campusId 关联' });
+    },
+  },
+  /* 63. POST /api/admin/maintenance/activity-sync —— 活动数据同步(mode=used-log|signed) */
+  {
+    method: 'POST',
+    path: '/api/admin/maintenance/activity-sync',
+    handler: (ctx) => {
+      const denied = requireSuper(ctx);
+      if (denied) return denied;
+      const mode = ctx.body?.mode as string | undefined;
+      if (mode !== 'used-log' && mode !== 'signed') return fail(2001, 'mode 缺失或非法(须 used-log 或 signed)');
+      // mock:按 mode 同步 5 活动(数据源 data.ts 5 活动)
+      const affected = mode === 'signed' ? 6 : 4; // signed=6 报名 / used-log=4 已用流水
+      return ok({ affected, message: `已按 ${mode} 口径同步 ${affected} 条活动数据` });
+    },
+  },
+];

+ 229 - 0
src/api/mock/routes/payroll.ts

@@ -0,0 +1,229 @@
+/**
+ * routes/payroll.ts —— 课酬 / 收支端点 mock(academic 契约 §2.5 端点 58~60,3 个)。
+ *
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ *   - 端点 58 课酬方案:GET/POST/PUT `/admin/payroll/plans[/:id]`(actived 启停 + effectDate 生效日;
+ *     teacherExp/percentage/min/max/reward 为方案参数,金额恒字符串两位小数,percentage 为 0~1 小数比);
+ *   - 端点 59 课酬测算:POST `/admin/payroll/quote`(teacherId,schoolId,campusId,classifyId,teacherExp,date)
+ *     → {detailId,percentage,min,max,reward};**3112(课酬方案未配置)→ 引导去配置**;
+ *   - 端点 60 收支:GET `/admin/expenditures` 收支记录(分页)/ GET `/admin/expenditures/types` 类型字典 /
+ *     POST `/admin/expenditures` 新增收支记录(type 须来自字典,direction 定收支方向)。
+ *
+ * 数据源:课酬方案 / 收支类型字典均来自 data.ts(queryPayrollPlans / findPayrollPlan / expenditureTypes /
+ *   expenditureTypeOf);收支记录为模块级单例数组。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, ok, page } from '@/api/mock/envelope';
+import { fmt, toCents } from '@/api/mock/money';
+import { rfc3339 } from '@/api/mock/time';
+import { inScope, findUser, queryPayrollPlans, findPayrollPlan, expenditureTypes, expenditureTypeOf } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+/** 课酬方案行 → 输出(金额字符串、percentage 保留 0~1 小数)。入参:plan。出参:对象。 */
+interface PlanSeed {
+  id: number;
+  teacherId: string;
+  schoolId: number;
+  campusId: number;
+  classifyId: number;
+  teacherExp: number;
+  percentage: number;
+  min: number;
+  max: number;
+  reward: number;
+  actived: 0 | 1;
+  effectDate: string;
+}
+/** 模块级课酬方案种子(data.ts 提供只读初值;本文件做可变副本以支持 POST/PUT)。 */
+const plans: PlanSeed[] = queryPayrollPlans({}).map((p) => ({ ...p }));
+
+/** 收支记录(模块级单例;dev 会话内可变)。 */
+interface Expenditure {
+  id: number;
+  type: number;
+  name: string;
+  direction: 1 | -1;
+  money: number; // 分
+  userId: string;
+  campusId: number;
+  bizNo: string;
+  createAt: string;
+}
+let expSeq = 8000;
+const expenditures: Expenditure[] = [
+  { id: ++expSeq, type: 101, name: '课程消费(校区)', direction: -1, money: 10000, userId: '01J2M201000000000000000', campusId: 11, bizNo: 'EXP20261101001', createAt: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
+  { id: ++expSeq, type: 300, name: '线下收入', direction: 1, money: 50000, userId: '01J2M201000000000000000', campusId: 11, bizNo: 'EXP20261102002', createAt: rfc3339(Date.parse('2026-11-02T11:00:00+08:00')) },
+];
+
+function checkScope(ctx: MockCtx): R<undefined> | undefined {
+  const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
+  const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
+  return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+function planRow(p: PlanSeed): Record<string, unknown> {
+  return {
+    id: p.id,
+    teacherId: p.teacherId,
+    schoolId: p.schoolId,
+    campusId: p.campusId,
+    classifyId: p.classifyId,
+    teacherExp: p.teacherExp,
+    percentage: p.percentage,
+    min: fmt(p.min),
+    max: fmt(p.max),
+    reward: fmt(p.reward),
+    actived: p.actived,
+    effectDate: p.effectDate,
+  };
+}
+
+export const payrollRoutes: MockRoute[] = [
+  /* 58a. GET /api/admin/payroll/plans —— 课酬方案列表 */
+  {
+    method: 'GET',
+    path: '/api/admin/payroll/plans',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      let rows = plans;
+      if (ctx.query.actived !== undefined) rows = rows.filter((p) => p.actived === Number(ctx.query.actived));
+      if (ctx.query.classifyId !== undefined) rows = rows.filter((p) => p.classifyId === Number(ctx.query.classifyId));
+      if (ctx.query.teacherId) rows = rows.filter((p) => p.teacherId === ctx.query.teacherId);
+      return page(rows.map(planRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 58b. POST /api/admin/payroll/plans —— 新建课酬方案 */
+  {
+    method: 'POST',
+    path: '/api/admin/payroll/plans',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const teacherId = ctx.body?.teacherId as string | undefined;
+      if (!teacherId || !findUser(teacherId)) return fail(2001, 'teacherId 缺失或用户不存在');
+      const p: PlanSeed = {
+        id: Date.now(),
+        teacherId,
+        schoolId: Number(ctx.body?.schoolId ?? 1),
+        campusId: Number(ctx.body?.campusId ?? 0),
+        classifyId: Number(ctx.body?.classifyId ?? 0),
+        teacherExp: Number(ctx.body?.teacherExp ?? 0),
+        percentage: Number(ctx.body?.percentage ?? 0),
+        min: toCents(String(ctx.body?.min ?? '0.00')),
+        max: toCents(String(ctx.body?.max ?? '0.00')),
+        reward: toCents(String(ctx.body?.reward ?? '0.00')),
+        actived: Number(ctx.body?.actived ?? 0) === 1 ? 1 : 0,
+        effectDate: String(ctx.body?.effectDate ?? '2026-09-01'),
+      };
+      plans.push(p);
+      return ok({ id: p.id });
+    },
+  },
+  /* 58c. PUT /api/admin/payroll/plans/:id —— 更新课酬方案(actived 启停 / effectDate 生效日) */
+  {
+    method: 'PUT',
+    path: '/api/admin/payroll/plans/:id',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const p = plans.find((x) => x.id === Number(ctx.params.id)) ?? findPayrollPlan(Number(ctx.params.id));
+      if (!p) return fail(3001, '课酬方案不存在');
+      if (ctx.body?.actived !== undefined) (p as PlanSeed).actived = Number(ctx.body.actived) === 1 ? 1 : 0;
+      if (ctx.body?.effectDate !== undefined) (p as PlanSeed).effectDate = String(ctx.body.effectDate);
+      if (ctx.body?.percentage !== undefined) (p as PlanSeed).percentage = Number(ctx.body.percentage);
+      return ok({ id: p.id });
+    },
+  },
+  /* 59. POST /api/admin/payroll/quote —— 课酬测算(3112:方案未配置) */
+  {
+    method: 'POST',
+    path: '/api/admin/payroll/quote',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const teacherId = ctx.body?.teacherId as string | undefined;
+      const classifyId = Number(ctx.body?.classifyId ?? 0);
+      if (!teacherId || !findUser(teacherId) || classifyId <= 0) return fail(2001, 'teacherId/classifyId 缺失或非法');
+      const plan = plans.find((p) => p.teacherId === teacherId && p.classifyId === classifyId && p.actived === 1);
+      // 3112:无生效方案 → 引导去配置
+      if (!plan) return fail(3112, '课酬方案未配置,请先配置该老师该科目的生效方案');
+      return ok({
+        detailId: Date.now(),
+        percentage: plan.percentage,
+        min: fmt(plan.min),
+        max: fmt(plan.max),
+        reward: fmt(plan.reward),
+      });
+    },
+  },
+  /* 60a. GET /api/admin/expenditures —— 收支记录(分页 + type/direction 筛选) */
+  {
+    method: 'GET',
+    path: '/api/admin/expenditures',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      let rows = expenditures;
+      if (ctx.query.type !== undefined) rows = rows.filter((e) => e.type === Number(ctx.query.type));
+      if (ctx.query.direction !== undefined) rows = rows.filter((e) => e.direction === Number(ctx.query.direction));
+      if (ctx.query.userId) rows = rows.filter((e) => e.userId === ctx.query.userId);
+      const mapped = rows
+        .slice()
+        .sort((a, b) => (a.createAt < b.createAt ? 1 : -1))
+        .map((e) => ({
+          id: e.id,
+          type: e.type,
+          name: e.name,
+          direction: e.direction,
+          money: fmt(e.money),
+          userId: e.userId,
+          campusId: e.campusId,
+          bizNo: e.bizNo,
+          createAt: e.createAt,
+        }));
+      return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* 60b. GET /api/admin/expenditures/types —— 收支类型字典 */
+  {
+    method: 'GET',
+    path: '/api/admin/expenditures/types',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      return ok({ types: expenditureTypes.map((t) => ({ type: t.type, name: t.name, direction: t.direction })) });
+    },
+  },
+  /* 60c. POST /api/admin/expenditures —— 新增收支记录 */
+  {
+    method: 'POST',
+    path: '/api/admin/expenditures',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      const type = Number(ctx.body?.type ?? 0);
+      const dict = expenditureTypeOf(type);
+      if (!dict) return fail(2001, 'type 缺失或不在收支字典内');
+      const money = toCents(String(ctx.body?.money ?? '0.00'));
+      if (money <= 0) return fail(2002, '金额须大于 0');
+      const userId = ctx.body?.userId as string | undefined;
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      const e: Expenditure = {
+        id: ++expSeq,
+        type,
+        name: dict.name,
+        direction: dict.direction,
+        money,
+        userId,
+        campusId: Number(ctx.body?.campusId ?? 0),
+        bizNo: `EXP${expSeq}`,
+        createAt: rfc3339(Date.now()),
+      };
+      expenditures.unshift(e);
+      return ok({ id: e.id, bizNo: e.bizNo });
+    },
+  },
+];

+ 49 - 0
src/api/mock/time.ts

@@ -0,0 +1,49 @@
+/**
+ * time.ts —— mock 时间辅助(academic-mock-plan §4:时间恒 RFC3339 带 +08:00 时区偏移)。
+ *
+ * 契约来源:academic-frontend-contract-v1.md §1.5(冻结):
+ *   后端输出 RFC3339(Go RFC3339Nano)恒 +08:00,形如 "2026-11-09T10:00:00.123+08:00";
+ *   前端用 dayjs/Date 直接解析,禁止按 "YYYY-MM-DD HH:mm:ss" 正则切。
+ * 查询入参日期例外:前端传 "YYYY-MM-DD"(RFC3339 只约束**输出**)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载,不进 dist。
+ */
+
+const pad = (n: number, w = 2): string => String(n).padStart(w, '0');
+
+/**
+ * 生成 RFC3339 时间串(恒 +08:00,带毫秒)。
+ *
+ * 入参:
+ *   - d Date | number | string(可省略 = 当前时刻;number 为 Unix 毫秒)。
+ *
+ * 出参:
+ *   - string:形如 "2026-11-09T10:00:00.123+08:00"。
+ *
+ * 异常场景:
+ *   - 入参非法(NaN):Date 归一为 0,输出 1970-01-01(mock 场景不会触发;类型系统拦截大部分误用)。
+ */
+export function rfc3339(d?: Date | number | string): string {
+  const t = new Date(d ?? Date.now());
+  return (
+    `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}` +
+    `T${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}.${pad(t.getMilliseconds(), 3)}` +
+    '+08:00'
+  );
+}
+
+/**
+ * "YYYY-MM-DD" 解析为当日 00:00:00 的 Date(mock 侧处理查询入参日期区间用)。
+ *
+ * 入参:
+ *   - s string:"YYYY-MM-DD"(契约 §1.5 查询入参约定)。
+ *
+ * 出参:
+ *   - Date | null:解析成功 = 当日零点;格式非法 = null(调用方按参数缺失处理)。
+ */
+export function parseDate(s: string | undefined | null): Date | null {
+  if (!s) return null;
+  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
+  if (!m) return null;
+  return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
+}