3 Commity d08c5d435a ... fdb94de38c

Autor SHA1 Wiadomość Data
  Q fdb94de38c feat(org): 补齐 org 域 mock + 10 业务页 + 费用页操作面(打通业务页 mock) 10 godzin temu
  Q d4b563853f feat(ui): 登录/作用域页对齐原型 + 记住上次作用域 10 godzin temu
  Q 32ed17b544 feat(academic): 落地 PC 端 mock 联调(axios adapter 拦截,58 端点 + 8 异常分支) 19 godzin temu

+ 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

+ 62 - 0
src/api/academic/activity.ts

@@ -0,0 +1,62 @@
+/**
+ * activity.ts —— 活动接口封装(academic 域;端点 22~37)。
+ *
+ * 典型使用(views/academic/activity/index.vue):
+ *   useListPage<ActivityDTO> fetcher=listActivities({...scope.toQuery(), keyword, page, pageSize})
+ *
+ * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
+ *   - 金额字符串两位小数;时间 RFC3339;分页 PageData 五字段;status 为数字码(中文前端映射)。
+ */
+import { http } from '@/api/http';
+import type { PageR } from '@/types/api';
+
+/** 活动 DTO(端点 22 列表行) */
+export interface ActivityDTO {
+  /** 活动 id(int64 主键) */
+  id: number;
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(0 = 机构级) */
+  campusId: number;
+  /** 活动类型编码(2 充值赠送 / 3 老带新 / 4 特殊费用) */
+  type: number;
+  /** 活动名称 */
+  name: string;
+  /** 状态数字码(前端映射中文) */
+  status: number;
+  /** 报名单号 */
+  numberSign: string;
+  /** 名额总数 */
+  total: number;
+  /** 已报名人数 */
+  signed: number;
+  /** 是否可报名 */
+  signable: boolean;
+  /** 开始时间(RFC3339) */
+  startAt: string;
+  /** 结束时间(RFC3339) */
+  endAt: string;
+}
+
+/** 活动列表查询参数(作用域由 useScope().toQuery() 注入) */
+export type ActivityQuery = {
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 名称模糊搜索(可空) */
+  keyword?: string;
+  /** 活动类型筛选(可空) */
+  type?: number;
+  /** 状态筛选(可空) */
+  status?: number;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 活动分页列表(端点 22,GET /api/admin/activities)。 */
+export function listActivities(params: ActivityQuery): Promise<PageR<ActivityDTO>> {
+  return http.get<PageR<ActivityDTO>>('/api/admin/activities', { params }).then((r) => r.data);
+}

+ 52 - 0
src/api/academic/course.ts

@@ -0,0 +1,52 @@
+/**
+ * course.ts —— 课程/排班接口封装(academic 域;端点 44~56)。
+ *
+ * 典型使用(views/academic/schedule/index.vue):
+ *   useListPage<CourseDTO> fetcher=listCourses({...scope.toQuery(), keyword, page, pageSize})
+ *
+ * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
+ *   - 金额字符串两位小数;时间 RFC3339;分页 PageData 五字段;status 数字码(前端映射中文)。
+ */
+import { http } from '@/api/http';
+import type { PageR } from '@/types/api';
+
+/** 课程 DTO(端点 44 列表行) */
+export interface CourseDTO {
+  /** 课程 id(int64 主键) */
+  id: number;
+  /** 机构 id */
+  institutionId: number;
+  /** 课程名称 */
+  name: string;
+  /** 课程分类(学科) */
+  classify: string;
+  /** 状态数字码(前端映射中文) */
+  status: number;
+  /** 授课老师 id 列表 */
+  teacherIds: string[];
+  /** 排班实例(课表)数量 */
+  recordCount: number;
+}
+
+/** 课程列表查询参数(作用域由 useScope().toQuery() 注入) */
+export type CourseQuery = {
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 课程分类筛选(可空) */
+  classify?: string;
+  /** 状态筛选(可空) */
+  status?: number;
+  /** 名称模糊搜索(可空) */
+  keyword?: string;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 课程分页列表(端点 44,GET /api/admin/courses)。 */
+export function listCourses(params: CourseQuery): Promise<PageR<CourseDTO>> {
+  return http.get<PageR<CourseDTO>>('/api/admin/courses', { params }).then((r) => r.data);
+}

+ 42 - 0
src/api/academic/divide-into.ts

@@ -0,0 +1,42 @@
+/**
+ * divide-into.ts —— 分账接口封装(academic 域;端点 38~43,分账树)。
+ *
+ * 分账是**树形**结构(上级 → 下线 → …,最多 3 层),非分页列表,故本域返回 { tree } 而非 PageData。
+ * 典型使用(views/academic/divide/index.vue):拉 getDivideTree() 后用 el-table 的 tree-props 渲染。
+ *
+ * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
+ *   - quota 为金额字符串(每次分成金额,v1.6 已从比率改金额);累计收入字符串两位小数;时间 RFC3339。
+ */
+import { http } from '@/api/http';
+import type { R } from '@/types/api';
+
+/** 分账节点 DTO(递归树;与 mock nodeRow 逐字段对齐) */
+export interface DivideNodeDTO {
+  /** 节点(用户)id(canonical_user_id ULID 字符串) */
+  userId: string;
+  /** 真实姓名 */
+  realName: string;
+  /** 上级用户 id(根节点为空串/缺省) */
+  parentUserId?: string;
+  /** 分成额度(金额字符串,每次分成金额;0.00 = 该链不分成) */
+  quota: string;
+  /** 分成累计收入(字符串两位小数) */
+  cumulativeIncome: string;
+  /** 子节点(递归;根为顶层数组) */
+  children: DivideNodeDTO[];
+}
+
+/** 分账树查询参数(作用域由 useScope().toQuery() 注入;rootUserId 可定位子树) */
+export type DivideTreeQuery = {
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 根用户 id(可空 = 取全树) */
+  rootUserId?: string;
+};
+
+/** 分账树(端点 38,GET /api/admin/divide-into/tree)。出参:R<{ tree: DivideNodeDTO[] }>。 */
+export function getDivideTree(params: DivideTreeQuery): Promise<R<{ tree: DivideNodeDTO[] }>> {
+  return http.get<R<{ tree: DivideNodeDTO[] }>>('/api/admin/divide-into/tree', { params }).then((r) => r.data);
+}

+ 262 - 0
src/api/academic/finance.ts

@@ -0,0 +1,262 @@
+/**
+ * finance.ts —— 费用管理接口封装(academic 域;对应旧 PHP 财务可操作面)。
+ *
+ * 覆盖旧系统用户可操作方法(对齐 modules/user + courses/RequestController):
+ *   账户快照/可退额、充值、消费、退费、自定义退费(§3.1)、转结、坏账、余额清零(§3.2)、
+ *   spend 扣减、资金工单(建/审/驳/撤)、流水查询、仪表盘。
+ *
+ * 典型使用(views/academic/fee/index.vue):
+ *   - 流水列表:useListPage<ExpenseLogDTO> fetcher=listLogs({...scope.toQuery(), userId, page, pageSize})
+ *   - 各操作:直接调对应方法,后端恒 200 + body code;业务错误由调用方判 code
+ *
+ * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
+ *   - 金额恒字符串两位小数(元);时间 RFC3339;分页 PageData 五字段;
+ *   - 写操作带 requestId 走幂等(重复提交 → repeated=true 回放首次)。
+ */
+import { http } from '@/api/http';
+import type { PageR, R } from '@/types/api';
+
+/* ================= 读 ================= */
+
+/** 资金流水 DTO(端点 11 列表行) */
+export interface ExpenseLogDTO {
+  /** 流水 id(int64 主键) */
+  id: number;
+  /** 用户 id(canonical_user_id ULID 字符串) */
+  userId: string;
+  /** 机构 id(作用域第一级) */
+  institutionId: number;
+  /** 校区 id(作用域第二级;0 = 机构级) */
+  campusId: number;
+  /** 流水类型编码(100 充值 + / 101,201 消费 − / 207 退费 − / 208 清零 − / 106,206 转结 / 108 坏账 +) */
+  type: number;
+  /** 金额(字符串两位小数) */
+  money: string;
+  /** 变动后余额(字符串两位小数) */
+  balanceAfter: string;
+  /** 创建时间(RFC3339) */
+  createAt: string;
+  /** 备注 */
+  remark: string;
+}
+
+/** 流水查询参数(作用域由 useScope().toQuery() 注入;userId 定位账户) */
+export type LogQuery = {
+  institutionId: number;
+  campusId?: number;
+  userId?: string;
+  type?: number;
+  from?: string;
+  to?: string;
+  page?: number;
+  pageSize?: number;
+};
+
+/** 资金流水分页列表(端点 11,GET /api/admin/finance/logs)。 */
+export function listLogs(params: LogQuery): Promise<PageR<ExpenseLogDTO>> {
+  return http.get<PageR<ExpenseLogDTO>>('/api/admin/finance/logs', { params }).then((r) => r.data);
+}
+
+/** 账户快照 DTO(端点 1;扁平结构) */
+export interface AccountDTO {
+  userId: string;
+  institutionId: number;
+  campusId: number;
+  totalBalance: string;
+  campusBalance: string;
+  specialExpenses: string;
+  cumulativeIncome: string;
+}
+
+/** 账户快照(端点 1,GET /api/admin/finance/accounts/:userId)。 */
+export function fetchAccount(userId: string, params: { institutionId: number; campusId?: number }): Promise<R<AccountDTO>> {
+  return http.get<R<AccountDTO>>(`/api/admin/finance/accounts/${userId}`, { params }).then((r) => r.data);
+}
+
+/** 可退额(端点 2,GET /api/admin/finance/accounts/:userId/refundable)。 */
+export function fetchRefundable(
+  userId: string,
+  params: { institutionId: number; campusId?: number },
+): Promise<R<{ refundableAmount: string }>> {
+  return http
+    .get<R<{ refundableAmount: string }>>(`/api/admin/finance/accounts/${userId}/refundable`, { params })
+    .then((r) => r.data);
+}
+
+/** 仪表盘 DTO(端点 21;账户维度 + 校区余额分布) */
+export interface DashboardDTO {
+  balance: string;
+  totalIncome: string;
+  totalExpenditure: string;
+  specialExpenses: string;
+  netReceivable: string;
+  balanceDistribution: { campusId: number; balance: string }[];
+}
+
+/** 仪表盘(端点 21,GET /api/admin/finance/dashboard)。 */
+export function fetchDashboard(params: { institutionId: number; campusId?: number; userId?: string }): Promise<R<DashboardDTO>> {
+  return http.get<R<DashboardDTO>>('/api/admin/finance/dashboard', { params }).then((r) => r.data);
+}
+
+/* ================= 写(用户可操作;对齐旧 PHP 财务方法) ================= */
+
+/** 资金工单 DTO(端点 12) */
+export interface BalanceRequestDTO {
+  id: number;
+  bizNo: string;
+  type: number;
+  userId: string;
+  institutionId: number;
+  campusId: number;
+  money: string;
+  approved: number; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
+  applicantId: string;
+  approverId: string;
+  remark: string;
+  createAt: string;
+}
+
+/** 工单列表(端点 12,GET /api/admin/finance/requests)。 */
+export function listRequests(params: {
+  institutionId: number;
+  campusId?: number;
+  type?: number;
+  approved?: number;
+  userId?: string;
+  page?: number;
+  pageSize?: number;
+}): Promise<PageR<BalanceRequestDTO>> {
+  return http.get<PageR<BalanceRequestDTO>>('/api/admin/finance/requests', { params }).then((r) => r.data);
+}
+
+/** 充值(端点 3,POST /api/admin/finance/recharges)。入参:userId+campusId+money+requestId。 */
+export function recharge(body: {
+  userId: string;
+  campusId: number;
+  money: string;
+  requestId: string;
+  institutionId: number;
+}): Promise<R<{ logId: number; balance: string; repeated: boolean }>> {
+  return http.post<R<{ logId: number; balance: string; repeated: boolean }>>('/api/admin/finance/recharges', body).then((r) => r.data);
+}
+
+/** 消费(端点 4,POST /api/admin/finance/expenditures)。 */
+export function expenditure(body: {
+  userId: string;
+  campusId: number;
+  type?: number;
+  money: string;
+  institutionId: number;
+}): Promise<R<{ logId: number; balance: string }>> {
+  return http.post<R<{ logId: number; balance: string }>>('/api/admin/finance/expenditures', body).then((r) => r.data);
+}
+
+/** 退费(端点 5,POST /api/admin/finance/refunds)。余额≤0 → 3102。 */
+export function refund(body: {
+  userId: string;
+  campusId: number;
+  money: string;
+  institutionId: number;
+}): Promise<R<{ logId: number; balance: string; changed: boolean }>> {
+  return http.post<R<{ logId: number; balance: string; changed: boolean }>>('/api/admin/finance/refunds', body).then((r) => r.data);
+}
+
+/** 自定义退费结果(端点 6 §3.1;可退不足 → 3103 warn + badDebt;校区不存在 → 3104) */
+export interface CustomRefundResult {
+  requested: string;
+  refunded: string;
+  badDebt: string;
+  refundableAmount: string;
+  balance: string;
+  totalBalance: string;
+  totalIncome: string;
+  specialExpenses: string;
+  logId207: number;
+  logId108: number | null;
+  expiredActivityIds: number[];
+  warnings: { code: number; message: string }[];
+  repeated: boolean;
+}
+
+/** 自定义退费(端点 6,POST /api/admin/finance/refunds/custom)。 */
+export function customRefund(body: {
+  userId: string;
+  campusId: number;
+  money: string;
+  requestId: string;
+  institutionId: number;
+}): Promise<R<CustomRefundResult>> {
+  return http.post<R<CustomRefundResult>>('/api/admin/finance/refunds/custom', body).then((r) => r.data);
+}
+
+/** 转结(端点 7,POST /api/admin/finance/transfers;校区间转账)。 */
+export function transfer(body: {
+  userId: string;
+  fromCampusId: number;
+  toCampusId: number;
+  money: string;
+  institutionId: number;
+}): Promise<R<{ logId106: number; logId206: number }>> {
+  return http.post<R<{ logId106: number; logId206: number }>>('/api/admin/finance/transfers', body).then((r) => r.data);
+}
+
+/** 坏账(端点 8,POST /api/admin/finance/bad-debts)。 */
+export function badDebt(body: {
+  userId: string;
+  campusId: number;
+  money: string;
+  requestId: string;
+  institutionId: number;
+}): Promise<R<{ logId: number; specialExpenses: string; repeated: boolean }>> {
+  return http.post<R<{ logId: number; specialExpenses: string; repeated: boolean }>>('/api/admin/finance/bad-debts', body).then((r) => r.data);
+}
+
+/** 余额清零结果(端点 9 §3.2;前置不满足 → 3120) */
+export interface ClearBalanceResult {
+  cleared: string;
+  campusBalanceBefore: string;
+  totalBalanceBefore: string;
+  balance: string;
+  totalBalance: string;
+  specialExpenses: string;
+  logId208: number;
+  expiredActivityIds: number[];
+  repeated: boolean;
+}
+
+/** 余额清零(端点 9,POST /api/admin/finance/cleared-balances)。 */
+export function clearBalance(body: { userId: string; campusId: number; institutionId: number }): Promise<R<ClearBalanceResult>> {
+  return http.post<R<ClearBalanceResult>>('/api/admin/finance/cleared-balances', body).then((r) => r.data);
+}
+
+/** spend 扣减(端点 10,POST /api/admin/finance/spends;课时扣费)。 */
+export function spend(body: { userId: string; campusId: number; spendMoney: string; institutionId: number }): Promise<R<{ spend: string; logId: number }>> {
+  return http.post<R<{ spend: string; logId: number }>>('/api/admin/finance/spends', body).then((r) => r.data);
+}
+
+/** 建工单(端点 13,POST /api/admin/finance/requests)。 */
+export function createRequest(body: {
+  type: number;
+  userId: string;
+  campusId: number;
+  money: string;
+  remark?: string;
+  institutionId: number;
+}): Promise<R<{ id: number; bizNo: string }>> {
+  return http.post<R<{ id: number; bizNo: string }>>('/api/admin/finance/requests', body).then((r) => r.data);
+}
+
+/** 审批通过(端点 14,POST /api/admin/finance/requests/:id/approve)。已处理 → 3140。 */
+export function approveRequest(id: number, params: { institutionId: number; campusId?: number }): Promise<R<{ approved: number; result: { logId: number; balance: string } }>> {
+  return http.post<R<{ approved: number; result: { logId: number; balance: string } }>>(`/api/admin/finance/requests/${id}/approve`, {}, { params }).then((r) => r.data);
+}
+
+/** 驳回(端点 15,POST /api/admin/finance/requests/:id/reject)。 */
+export function rejectRequest(id: number, params: { institutionId: number; campusId?: number }): Promise<R<{ approved: number }>> {
+  return http.post<R<{ approved: number }>>(`/api/admin/finance/requests/${id}/reject`, {}, { params }).then((r) => r.data);
+}
+
+/** 撤销(端点 16,POST /api/admin/finance/requests/:id/cancel;仅待审)。 */
+export function cancelRequest(id: number, params: { institutionId: number; campusId?: number }): Promise<R<{ approved: number }>> {
+  return http.post<R<{ approved: number }>>(`/api/admin/finance/requests/${id}/cancel`, {}, { params }).then((r) => r.data);
+}

+ 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,
+  });
+}

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

@@ -0,0 +1,51 @@
+/**
+ * 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 { orgRoutes } from '@/api/mock/routes/org';
+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,
+    ...orgRoutes,
+    ...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} 条活动数据` });
+    },
+  },
+];

+ 156 - 0
src/api/mock/routes/org.ts

@@ -0,0 +1,156 @@
+/**
+ * routes/org.ts —— 机构/校区/成员端点 mock(org 域;身份地基,README §3.2 一资源一文件)。
+ *
+ * 为何要 mock org 域:PC 端落地页 `/org/institutions`(机构管理)与菜单「机构/校区」「成员/班级」
+ *   都打 `/api/org/*`。org 域是作用域源头(ADR-006/007),不 mock 则进入工作台第一屏即撞
+ *   "mock 未覆盖端点 GET /api/org/institutions"(adapter 兜底 3001 → 响应拦截器 toast)。
+ *   本文件补齐 org 三端点,让落地页 + 两个 org 菜单项真能出数据。
+ *
+ * 契约对齐(org 域 model,与 src/api/org/*.ts 的 DTO 逐字段对齐 camelCase):
+ *   - 分页恒 PageData 五字段 {list,total,page,pageSize,pages}(envelope.page 收口,pageSize 上限 200);
+ *   - 作用域越界 → 1004(checkScope,复用 data.inScope:institutionId∈{1,0} 且 campusId∈校区集合);
+ *   - 机构 status 为字符串枚举 "active"|"disabled"(org 域 VARCHAR(16),非 0/1);
+ *   - 时间恒 RFC3339 +08:00(time.rfc3339),机构/成员为 GORM 审计时间(createdAt/updatedAt)。
+ *
+ * 数据来源(复用 data.ts 固定种子,保证与登录下发作用域、学术域数据一致):
+ *   - 机构:固定 3 家(#1 源思维教育=本 mock 主机构,绑定校区 11/12/13;#2/#3 停用样例);
+ *   - 校区:data.campuses(11 滨江 / 12 城西 / 13 高新,均挂机构 #1);
+ *   - 成员:data.users(10 名,含 staff/student 与 role)。
+ *
+ * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
+ */
+import { fail, page } from '@/api/mock/envelope';
+import { rfc3339 } from '@/api/mock/time';
+import { inScope, campuses as seedCampuses, users as seedUsers } from '@/api/mock/data';
+import type { MockCtx, MockRoute } from '@/api/mock/adapter';
+import type { R } from '@/types/api';
+
+/** 作用域越界校验(与学术域各路由同一口径)。入参: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, '作用域越界:当前账号无权访问该机构/校区');
+}
+
+/**
+ * 机构种子(固定,确定性)。
+ * #1 = 本 mock 主机构(登录作用域 institutionId=1 即它,绑定 3 校区);#2/#3 = 停用样例,凑分页/筛选数据量。
+ */
+interface OrgInstitution {
+  institutionId: number;
+  institutionName: string;
+  institutionCode: string;
+  address: string;
+  phone: string;
+  status: 'active' | 'disabled';
+  createdAt: string;
+  updatedAt: string;
+}
+
+const BASE_MS = Date.parse('2026-11-09T12:00:00+08:00');
+const DAY = 86_400_000;
+const t = (offsetDays: number): string => rfc3339(BASE_MS + offsetDays * DAY);
+
+const institutions: OrgInstitution[] = [
+  {
+    institutionId: 1,
+    institutionName: '源思维教育',
+    institutionCode: 'YSW-001',
+    address: '滨江校区 · 智慧园区 8 号楼',
+    phone: '0571-8888****',
+    status: 'active',
+    createdAt: t(-320),
+    updatedAt: t(-3),
+  },
+  {
+    institutionId: 2,
+    institutionName: '源思维少儿',
+    institutionCode: 'YSW-002',
+    address: '城西校区 · 创意楼 2 层',
+    phone: '0571-6666****',
+    status: 'active',
+    createdAt: t(-200),
+    updatedAt: t(-12),
+  },
+  {
+    institutionId: 3,
+    institutionName: '源思维(旧)',
+    institutionCode: 'YSW-003',
+    address: '高新校区 · 培训大厦 5 层',
+    phone: '0571-5555****',
+    status: 'disabled',
+    createdAt: t(-500),
+    updatedAt: t(-60),
+  },
+];
+
+export const orgRoutes: MockRoute[] = [
+  /* GET /api/org/institutions —— 机构分页列表(落地页 /org/institutions 调) */
+  {
+    method: 'GET',
+    path: '/api/org/institutions',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      // 作用域机构级过滤:非集团(campusId>0 或显式机构 #N)时只返回该机构;机构级(=1)返回全部
+      let rows = institutions;
+      const kw = (ctx.query.keyword ?? '').trim();
+      if (kw) rows = rows.filter((x) => x.institutionName.includes(kw));
+      const st = ctx.query.status;
+      if (st) rows = rows.filter((x) => x.status === st);
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* GET /api/org/campuses —— 校区分页列表(菜单「机构/校区」→ /org/campuses) */
+  {
+    method: 'GET',
+    path: '/api/org/campuses',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      // 复用固定校区种子;按作用域校区过滤(campusId>0 时只返回该校区)
+      let rows = seedCampuses.map((c) => ({
+        campusId: c.campusId,
+        institutionId: c.institutionId,
+        campusName: c.name,
+        status: 'active' as const,
+        createdAt: t(-320),
+        updatedAt: t(-5),
+      }));
+      const kw = (ctx.query.keyword ?? '').trim();
+      if (kw) rows = rows.filter((x) => x.campusName.includes(kw));
+      const cid = Number(ctx.query.campusId ?? 0);
+      if (cid > 0) rows = rows.filter((x) => x.campusId === cid);
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+  /* GET /api/org/members —— 成员/班级分页列表(菜单「成员/班级」→ /org/members) */
+  {
+    method: 'GET',
+    path: '/api/org/members',
+    handler: (ctx) => {
+      const denied = checkScope(ctx);
+      if (denied) return denied;
+      let rows = seedUsers.map((u) => ({
+        userId: u.userId,
+        realName: u.realName,
+        personType: u.personType,
+        phone: u.phone,
+        role: u.role,
+        campusId: u.campusId,
+        status: 'active' as const,
+        createdAt: t(-150),
+        updatedAt: t(-1),
+      }));
+      const kw = (ctx.query.keyword ?? '').trim();
+      if (kw) rows = rows.filter((x) => x.realName.includes(kw));
+      const pt = ctx.query.personType;
+      if (pt) rows = rows.filter((x) => x.personType === pt);
+      const role = ctx.query.role;
+      if (role) rows = rows.filter((x) => x.role === role);
+      const cid = Number(ctx.query.campusId ?? 0);
+      if (cid > 0) rows = rows.filter((x) => x.campusId === cid);
+      return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+    },
+  },
+];

+ 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]));
+}

+ 45 - 0
src/api/org/campus.ts

@@ -0,0 +1,45 @@
+/**
+ * campus.ts —— 校区(campus)接口封装(org 域;README §3.2 一资源一文件)。
+ *
+ * 典型使用(views/org/campus/list.vue):
+ *   const { rows, total, page, pageSize, loading, fetchList } = useListPage<CampusDTO>({
+ *     fetcher: (p, ps) => listCampuses({ ...scope.toQuery(), keyword, page: p, pageSize: ps }),
+ *   });
+ */
+import { http } from '@/api/http';
+import type { PageR } from '@/types/api';
+
+/** 校区 DTO(字段与后端 org 域 model.Campus 出参逐字段对齐 camelCase;status 字符串枚举) */
+export interface CampusDTO {
+  /** 校区 id(int64 自增,主键) */
+  campusId: number;
+  /** 归属机构 id(作用域第一级) */
+  institutionId: number;
+  /** 校区名称 */
+  campusName: string;
+  /** 状态:"active" 启用 / "disabled" 停用(VARCHAR(16) 字符串枚举) */
+  status: 'active' | 'disabled';
+  /** 创建时间(RFC3339,后端 GORM CreatedAt) */
+  createdAt: string;
+  /** 更新时间(RFC3339,后端 GORM UpdatedAt) */
+  updatedAt: string;
+}
+
+/** 校区列表查询参数(作用域由 useScope().toQuery() 注入) */
+export type CampusQuery = {
+  /** 机构 id(作用域第一级) */
+  institutionId: number;
+  /** 校区 id(可空 = 机构级全部) */
+  campusId?: number;
+  /** 名称模糊搜索(可空) */
+  keyword?: string;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 校区分页列表(GET /api/org/campuses)。入参:CampusQuery。出参:PageR<CampusDTO>。 */
+export function listCampuses(params: CampusQuery): Promise<PageR<CampusDTO>> {
+  return http.get<PageR<CampusDTO>>('/api/org/campuses', { params }).then((r) => r.data);
+}

+ 56 - 0
src/api/org/member.ts

@@ -0,0 +1,56 @@
+/**
+ * member.ts —— 成员(member)接口封装(org 域;README §3.2 一资源一文件)。
+ *
+ * 成员 = 统一身份库中挂在某机构/校区下的人(staff/student/parent…),带角色与数据范围。
+ * 典型使用(views/org/member/list.vue):
+ *   const { rows, total, page, pageSize, loading, fetchList } = useListPage<MemberDTO>({
+ *     fetcher: (p, ps) => listMembers({ ...scope.toQuery(), keyword, page: p, pageSize: ps }),
+ *   });
+ */
+import { http } from '@/api/http';
+import type { PageR } from '@/types/api';
+
+/** 成员 DTO(字段与后端 org 域 model.Membership 出参逐字段对齐 camelCase) */
+export interface MemberDTO {
+  /** 成员(用户)id(canonical_user_id,ULID 字符串) */
+  userId: string;
+  /** 真实姓名 */
+  realName: string;
+  /** 人员类型:"staff" 教职工 / "student" 学生 / "parent" 家长 */
+  personType: 'staff' | 'student' | 'parent';
+  /** 脱敏手机号(后端已脱敏,前端直显不二次脱敏) */
+  phone: string;
+  /** 角色码:super_admin / academic / finance / teacher / student */
+  role: string;
+  /** 归属校区 id(0/缺省 = 机构级) */
+  campusId: number;
+  /** 状态:"active" 启用 / "disabled" 停用 */
+  status: 'active' | 'disabled';
+  /** 创建时间(RFC3339) */
+  createdAt: string;
+  /** 更新时间(RFC3339) */
+  updatedAt: string;
+}
+
+/** 成员列表查询参数(作用域由 useScope().toQuery() 注入) */
+export type MemberQuery = {
+  /** 机构 id(作用域第一级) */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 姓名模糊搜索(可空) */
+  keyword?: string;
+  /** 人员类型筛选(可空 = 全部) */
+  personType?: string;
+  /** 角色筛选(可空) */
+  role?: string;
+  /** 页码(缺省 1) */
+  page?: number;
+  /** 每页条数(缺省 10,上限 200) */
+  pageSize?: number;
+};
+
+/** 成员分页列表(GET /api/org/members)。入参:MemberQuery。出参:PageR<MemberDTO>。 */
+export function listMembers(params: MemberQuery): Promise<PageR<MemberDTO>> {
+  return http.get<PageR<MemberDTO>>('/api/org/members', { params }).then((r) => r.data);
+}

+ 66 - 0
src/router/index.ts

@@ -71,6 +71,72 @@ export const routes: RouteRecordRaw[] = [
         component: () => import('@/views/org/institution/list.vue'),
         meta: { title: '机构管理' },
       },
+      {
+        path: 'org/campuses',
+        name: 'CampusList',
+        component: () => import('@/views/org/campus/list.vue'),
+        meta: { title: '校区管理' },
+      },
+      {
+        path: 'org/members',
+        name: 'MemberList',
+        component: () => import('@/views/org/member/list.vue'),
+        meta: { title: '成员/班级' },
+      },
+      {
+        path: 'academic/fee',
+        name: 'FeeManage',
+        component: () => import('@/views/academic/fee/index.vue'),
+        meta: { title: '费用管理' },
+      },
+      {
+        path: 'academic/schedule',
+        name: 'Schedule',
+        component: () => import('@/views/academic/schedule/index.vue'),
+        meta: { title: '排班管理' },
+      },
+      {
+        path: 'academic/activity',
+        name: 'Activity',
+        component: () => import('@/views/academic/activity/index.vue'),
+        meta: { title: '活动' },
+      },
+      {
+        path: 'academic/divide',
+        name: 'Divide',
+        component: () => import('@/views/academic/divide/index.vue'),
+        meta: { title: '分账' },
+      },
+      {
+        path: 'teaching/exam',
+        name: 'TeachingExam',
+        component: () => import('@/views/teaching/exam/index.vue'),
+        meta: { title: '题库/组卷' },
+      },
+      {
+        path: 'teaching/analysis',
+        name: 'TeachingAnalysis',
+        component: () => import('@/views/teaching/analysis/index.vue'),
+        meta: { title: '练习分析' },
+      },
+      {
+        path: 'system/users',
+        name: 'SystemUsers',
+        component: () => import('@/views/system/users/index.vue'),
+        meta: { title: '用户/角色' },
+      },
+      {
+        path: 'system/tasks',
+        name: 'SystemTasks',
+        component: () => import('@/views/system/tasks/index.vue'),
+        meta: { title: '任务监控' },
+      },
+      {
+        path: 'system/logs',
+        name: 'SystemLogs',
+        component: () => import('@/views/system/logs/index.vue'),
+        meta: { title: '操作日志' },
+      },
     ],
   },
   {

+ 51 - 0
src/store/login.ts

@@ -69,12 +69,22 @@ export interface LoginStore {
    *   - access 过期:http 拦截器自动 refresh 重放,恢复失败才返回 false。
    */
   restoreMe: () => Promise<boolean>;
+  /** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */
+  rememberScope: (institutionId: number, campusId: number) => void;
+  /** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */
+  lastScope: () => { institutionId: number; campusId: number } | null;
   /** 执行登出(调后端吊销 → 清本地态) */
   doLogout: () => Promise<void>;
   /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
   hydrate: () => void;
 }
 
+/**
+ * 作用域偏好存储键(记住"上次进入的机构/校区",下次登录的作用域选择页默认选中该项)。
+ * 非凭证(不涉 token/票据),可安全持久化到 localStorage;doLogout 不清除(偏好跨登录保留)。
+ */
+export const LAST_SCOPE_KEY = 'ysw:last-scope';
+
 // /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
 let restoreInflight: Promise<boolean> | undefined;
 
@@ -161,6 +171,45 @@ export const useLogin = defineStore('login', (): LoginStore => {
     pendingTicket.value = null;
     pendingMemberships.value = [];
     restored.value = true;
+    // 记住本次作用域(机构>0 才记;campus 0 = 机构级也记,作为下次默认)
+    if (data.institutionId > 0) rememberScope(data.institutionId, data.campusId ?? 0);
+  }
+
+  /**
+   * 记住上次作用域(写 localStorage;下次登录的作用域选择页据此默认选中)。
+   *
+   * 入参:institutionId(>0 才记)/ campusId(0 = 机构级)。
+   * 出参:无。
+   * 异常场景:localStorage 不可用(隐私模式禁用):静默跳过,不影响登录流程。
+   */
+  function rememberScope(institutionId: number, campusId: number): void {
+    if (!institutionId || institutionId <= 0) return;
+    try {
+      localStorage.setItem(LAST_SCOPE_KEY, JSON.stringify({ institutionId, campusId: campusId ?? 0 }));
+    } catch {
+      /* 隐私模式等 storage 禁用:忽略(偏好丢失不阻断业务) */
+    }
+  }
+
+  /**
+   * 读取上次作用域(作用域选择页初始化用)。
+   *
+   * 入参:无。
+   * 出参:{ institutionId, campusId } | null(无记录 / 解析失败 = null,调用方回退首项)。
+   * 异常场景:JSON 解析失败 / 结构不符:返回 null(fail-open 回退默认首项)。
+   */
+  function lastScope(): { institutionId: number; campusId: number } | null {
+    try {
+      const raw = localStorage.getItem(LAST_SCOPE_KEY);
+      if (!raw) return null;
+      const p = JSON.parse(raw) as { institutionId?: number; campusId?: number };
+      if (typeof p.institutionId === 'number' && p.institutionId > 0) {
+        return { institutionId: p.institutionId, campusId: typeof p.campusId === 'number' ? p.campusId : 0 };
+      }
+      return null;
+    } catch {
+      return null;
+    }
   }
 
   /**
@@ -278,6 +327,8 @@ export const useLogin = defineStore('login', (): LoginStore => {
     hasPerm,
     applyStep1,
     applyLogin,
+    rememberScope,
+    lastScope,
     restoreMe,
     doLogout,
     hydrate,

+ 67 - 0
src/views/academic/activity/index.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+/**
+ * 活动页(views/academic/activity/index.vue)—— 活动列表(菜单「活动」,端点 22)。
+ * useListPage + ListPage;金额字符串两位小数;时间 RFC3339;status 数字码前端映射。
+ */
+import { listActivities, type ActivityDTO } from '@/api/academic/activity';
+import ListPage from '@/components/ListPage.vue';
+import type { ListColumn } from '@/components/list-column';
+import { useListPage } from '@/composables/use-list-page';
+import { useScope } from '@/store/scope';
+import { formatDateTime } from '@/utils/format';
+
+const scope = useScope();
+const filters = reactive<{ keyword: string; type: number | undefined }>({ keyword: '', type: undefined });
+
+const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<ActivityDTO>({
+  fetcher: (p, ps) => listActivities({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+});
+
+const typeText: Record<number, string> = { 2: '充值赠送', 3: '老带新', 4: '特殊费用' };
+const statusText: Record<number, string> = { 0: '未开始', 1: '报名中', 2: '进行中', 3: '已结束' };
+const columns: ListColumn<ActivityDTO>[] = [
+  { prop: 'id', label: '活动 id', minWidth: 90 },
+  { prop: 'name', label: '活动名称', minWidth: 160 },
+  { prop: 'type', label: '类型', minWidth: 90, formatter: (r) => typeText[r.type] ?? String(r.type) },
+  { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => statusText[r.status] ?? String(r.status) },
+  { prop: 'signed', label: '报名', minWidth: 80, formatter: (r) => `${r.signed}/${r.total}` },
+  { prop: 'startAt', label: '开始', minWidth: 140, formatter: (r) => formatDateTime(r.startAt) },
+  { prop: 'endAt', label: '结束', minWidth: 140, formatter: (r) => formatDateTime(r.endAt) },
+];
+
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+onMounted(() => {
+  void fetchList();
+});
+</script>
+
+<template>
+  <div class="activity-page">
+    <ListPage
+      v-model:page="page"
+      v-model:page-size="pageSize"
+      :rows="rows"
+      :total="total"
+      :loading="loading"
+      :error="error"
+      :columns="columns"
+      @search="onSearch"
+    >
+      <template #toolbar>
+        <el-input v-model="filters.keyword" placeholder="搜索活动名称" clearable class="toolbar-input" />
+        <el-select v-model="filters.type" placeholder="类型" clearable class="toolbar-select">
+          <el-option v-for="(t, k) in typeText" :key="k" :value="Number(k)" :label="t" />
+        </el-select>
+      </template>
+    </ListPage>
+  </div>
+</template>
+
+<style scoped>
+.activity-page {
+  width: 100%;
+}
+</style>

+ 66 - 0
src/views/academic/divide/index.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+/**
+ * 分账页(views/academic/divide/index.vue)—— 分账树(菜单「分账」,端点 38,树形非分页)。
+ * el-table tree-props 渲染上级→下线层级(最多 3 层);quota 为金额(每次分成金额)。
+ */
+import { getDivideTree, type DivideNodeDTO } from '@/api/academic/divide-into';
+import { useScope } from '@/store/scope';
+import { formatMoney } from '@/utils/format';
+
+const scope = useScope();
+const tree = ref<DivideNodeDTO[]>([]);
+const loading = ref(false);
+const error = ref('');
+
+async function load(): Promise<void> {
+  loading.value = true;
+  error.value = '';
+  try {
+    const r = await getDivideTree(scope.toQuery());
+    if (r.code === 200 && r.data) tree.value = r.data.tree;
+    else error.value = r.message || '加载失败';
+  } catch {
+    error.value = '网络连接异常';
+  } finally {
+    loading.value = false;
+  }
+}
+onMounted(() => {
+  void load();
+});
+</script>
+
+<template>
+  <div class="divide-page">
+    <div class="divide-page__bar">
+      <el-button @click="load" :loading="loading">刷新</el-button>
+      <span class="divide-page__hint">分账树:上级 → 下线(最多 3 层);额度 0.00 = 该链不分成</span>
+    </div>
+    <el-alert v-if="error" :title="error" type="error" show-icon class="divide-page__error" />
+    <el-table v-else :data="tree" :loading="loading" row-key="userId" :default-expand-all="true" :tree-props="{ children: 'children' }" border>
+      <el-table-column prop="realName" label="成员" min-width="160" />
+      <el-table-column label="分成额度(元)" min-width="130">
+        <template #default="{ row }">¥ {{ formatMoney(parseFloat(row.quota)) }}</template>
+      </el-table-column>
+      <el-table-column label="累计收入(元)" min-width="130">
+        <template #default="{ row }">¥ {{ formatMoney(parseFloat(row.cumulativeIncome)) }}</template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<style scoped>
+.divide-page {
+  width: 100%;
+}
+.divide-page__bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.divide-page__hint {
+  font-size: var(--app-font-md);
+  color: var(--app-text-secondary, #8a8f9c);
+}
+</style>

+ 393 - 0
src/views/academic/fee/index.vue

@@ -0,0 +1,393 @@
+<script setup lang="ts">
+/**
+ * 费用管理页(views/academic/fee/index.vue)—— 对齐旧 PHP 财务可操作面(菜单「费用管理」)。
+ *
+ * 结构(自上而下):
+ *   ① 作用域作用户 + 校区选择器(页面级状态;写操作必选账户);
+ *   ② 仪表盘卡片(端点 21:余额/累计充值/累计支出/特殊费用/应收净额 + 校区余额分布);
+ *   ③ el-tabs 三个页签:
+ *      - 流水(端点 11,useListPage 分页,type/日期筛选);
+ *      - 账户操作(充值/消费/退费/自定义退费/转结/坏账/清零/spend,各带对话框;
+ *        对齐旧 PHP:充值 recharges、消费 expenditures、退费 refunds、自定义退费 refunds/custom、
+ *        转结 transfers、坏账 bad-debts、清零 cleared-balances、spend spends);
+ *      - 资金工单(端点 12 列表 + 建单/审批/驳回/撤销,对齐旧 PHP RequestController)。
+ *
+ * 纪律:
+ *   - 作用域 institutionId/campusId 从 useScope().toQuery() 起步,页面级可覆盖校区;
+ *   - 写操作恒 200 + body code,业务错误(3102/3103/3104/3120/3140…)读 code 直显 message;
+ *   - 幂等:写操作生成一次性 requestId(防误触重复提交回放);
+ *   - 敏感操作(清零/坏账/自定义退费)二次确认(ElMessageBox.confirm,README §3.3-9)。
+ */
+import { listLogs, listRequests, fetchDashboard, recharge, expenditure, refund, customRefund, transfer, badDebt, clearBalance, spend, createRequest, approveRequest, rejectRequest, cancelRequest } from '@/api/academic/finance';
+import type { ExpenseLogDTO, BalanceRequestDTO, DashboardDTO } from '@/api/academic/finance';
+import { listCampuses } from '@/api/org/campus';
+import type { CampusDTO } from '@/api/org/campus';
+import { listMembers } from '@/api/org/member';
+import type { MemberDTO } from '@/api/org/member';
+import ListPage from '@/components/ListPage.vue';
+import type { ListColumn } from '@/components/list-column';
+import { useListPage } from '@/composables/use-list-page';
+import { useScope } from '@/store/scope';
+import { formatDateTime } from '@/utils/format';
+
+const scope = useScope();
+
+/** 命令式 EP(UMD 全局,http.ts 同款读法) */
+function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
+  const M = (globalThis.ElementPlus ?? {}).ElMessage;
+  if (M) M({ type, message: text });
+  else console.warn(`[${type}]`, text);
+}
+function ElConfirm(text: string): Promise<void> {
+  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
+  if (!B) return Promise.resolve();
+  return B.confirm(text, '敏感操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' }).then(() => undefined).catch(() => Promise.reject(new Error('cancel')));
+}
+
+/* ================= 作用户 + 校区 ================= */
+const memberOpts = ref<MemberDTO[]>([]);
+const campusOpts = ref<CampusDTO[]>([]);
+const selectedUserId = ref('');
+const selectedCampusId = ref<number>(0);
+
+onMounted(async () => {
+  const q = scope.toQuery();
+  const [m, c] = await Promise.all([
+    listMembers({ ...q, personType: 'student', pageSize: 200 }),
+    listCampuses({ ...q, pageSize: 200 }),
+  ]);
+  if (m.code === 200) {
+    memberOpts.value = m.data?.list ?? [];
+    if (memberOpts.value.length > 0 && !selectedUserId.value) selectedUserId.value = memberOpts.value[0].userId;
+  }
+  if (c.code === 200) {
+    campusOpts.value = c.data?.list ?? [];
+    if (campusOpts.value.length > 0 && selectedCampusId.value === 0) selectedCampusId.value = campusOpts.value[0].campusId;
+  }
+  void loadDashboard();
+});
+
+/* ================= 仪表盘 ================= */
+const dash = ref<DashboardDTO | null>(null);
+const campusName = (id: number): string => campusOpts.value.find((c) => c.campusId === id)?.campusName ?? `#${id}`;
+
+async function loadDashboard(): Promise<void> {
+  const r = await fetchDashboard({ ...scope.toQuery(), campusId: selectedCampusId.value || undefined });
+  if (r.code === 200 && r.data) dash.value = r.data;
+}
+
+/* ================= 流水(端点 11) ================= */
+const logFilters = reactive<{ type: number | undefined; from: string; to: string }>({ type: undefined, from: '', to: '' });
+const { rows: logs, total: logTotal, page: logPage, pageSize: logPageSize, loading: logLoading, error: logError, fetchList: fetchLogs } = useListPage<ExpenseLogDTO>({
+  fetcher: (p, ps) => listLogs({ ...scope.toQuery(), campusId: selectedCampusId.value || undefined, userId: selectedUserId.value || undefined, ...logFilters, page: p, pageSize: ps }),
+});
+onMounted(() => {
+  void fetchLogs();
+});
+
+/** 流水类型编码 → 中文(前端映射,后端只出数字码) */
+const typeText: Record<number, string> = { 100: '充值', 101: '消费', 106: '转结出', 108: '坏账', 201: '消费', 206: '转结入', 207: '退费', 208: '清零' };
+const logColumns: ListColumn<ExpenseLogDTO>[] = [
+  { prop: 'id', label: '流水号', minWidth: 90 },
+  { prop: 'type', label: '类型', minWidth: 90, formatter: (r) => typeText[r.type] ?? String(r.type) },
+  { prop: 'money', label: '金额', minWidth: 110, formatter: (r) => `${r.money}` },
+  { prop: 'balanceAfter', label: '余额(后)', minWidth: 110 },
+  { prop: 'remark', label: '备注', minWidth: 140 },
+  { prop: 'createAt', label: '时间', minWidth: 150, formatter: (r) => formatDateTime(r.createAt) },
+];
+function onLogSearch(): void {
+  logPage.value = 1;
+  void fetchLogs();
+}
+
+/* ================= 账户操作对话框 ================= */
+const opForm = reactive({ money: '', remark: '' });
+const transferForm = reactive({ fromCampus: 0, toCampus: 0, money: '' });
+const busy = ref(false);
+
+function nextRequestId(prefix: string): string {
+  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+}
+/** 统一执行包装:置忙 → 调 api → code 判定 toast → 刷新流水/仪表盘。入参:fn。 */
+async function runOp(fn: () => Promise<{ code: number; message: string }>): Promise<void> {
+  if (busy.value || !selectedUserId.value) return;
+  busy.value = true;
+  try {
+    const r = await fn();
+    if (r.code === 200) {
+      ElMessage_('success', '操作成功');
+      void fetchLogs();
+      void loadDashboard();
+    } else {
+      ElMessage_('warning', r.message || `操作失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    busy.value = false;
+  }
+}
+function doRecharge(): void {
+  void runOp(() => recharge({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('rc'), institutionId: scope.current.institutionId }));
+}
+function doExpenditure(): void {
+  void runOp(() => expenditure({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, institutionId: scope.current.institutionId }));
+}
+function doRefund(): void {
+  void runOp(() => refund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, institutionId: scope.current.institutionId }));
+}
+async function doCustomRefund(): Promise<void> {
+  try {
+    await ElConfirm('自定义退费将按可退额结算,不足部分计入坏账,确认执行?');
+  } catch {
+    return;
+  }
+  void runOp(() => customRefund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('cr'), institutionId: scope.current.institutionId }));
+}
+async function doBadDebt(): Promise<void> {
+  try {
+    await ElConfirm('确认对该账户做坏账处理?');
+  } catch {
+    return;
+  }
+  void runOp(() => badDebt({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('bd'), institutionId: scope.current.institutionId }));
+}
+async function doClear(): Promise<void> {
+  try {
+    await ElConfirm('余额清零为敏感操作,校区余额将一次性转特殊费用,确认执行?');
+  } catch {
+    return;
+  }
+  void runOp(() => clearBalance({ userId: selectedUserId.value, campusId: selectedCampusId.value, institutionId: scope.current.institutionId }));
+}
+function doSpend(): void {
+  void runOp(() => spend({ userId: selectedUserId.value, campusId: selectedCampusId.value, spendMoney: opForm.money, institutionId: scope.current.institutionId }));
+}
+function doTransfer(): void {
+  if (transferForm.fromCampus === 0 || transferForm.toCampus === 0 || transferForm.fromCampus === transferForm.toCampus) {
+    ElMessage_('warning', '请选择两个不同的校区');
+    return;
+  }
+  void runOp(() => transfer({ userId: selectedUserId.value, fromCampusId: transferForm.fromCampus, toCampusId: transferForm.toCampus, money: transferForm.money, institutionId: scope.current.institutionId }));
+}
+
+/* ================= 资金工单(端点 12~16) ================= */
+const reqFilters = reactive<{ approved: number | undefined }>({ approved: undefined });
+const { rows: reqs, total: reqTotal, page: reqPage, pageSize: reqPageSize, loading: reqLoading, error: reqError, fetchList: fetchReqs } = useListPage<BalanceRequestDTO>({
+  fetcher: (p, ps) => listRequests({ ...scope.toQuery(), campusId: selectedCampusId.value || undefined, ...reqFilters, page: p, pageSize: ps }),
+});
+onMounted(() => {
+  void fetchReqs();
+});
+const reqTypeText: Record<number, string> = { 1: '退费', 2: '清零', 3: '坏账', 4: '特殊' };
+const reqStatusText: Record<number, string> = { 0: '待审', 1: '已通过', '-1': '已驳回', '-2': '已撤销' };
+const reqColumns: ListColumn<BalanceRequestDTO>[] = [
+  { prop: 'bizNo', label: '单号', minWidth: 140 },
+  { prop: 'type', label: '类型', minWidth: 80, formatter: (r) => reqTypeText[r.type] ?? String(r.type) },
+  { prop: 'money', label: '金额', minWidth: 100 },
+  { prop: 'approved', label: '状态', minWidth: 90, formatter: (r) => reqStatusText[r.approved] ?? String(r.approved) },
+  { prop: 'remark', label: '备注', minWidth: 120 },
+  { prop: 'createAt', label: '申请时间', minWidth: 140, formatter: (r) => formatDateTime(r.createAt) },
+];
+function onReqSearch(): void {
+  reqPage.value = 1;
+  void fetchReqs();
+}
+function doCreateRequest(): void {
+  void runOp(() => createRequest({ type: 1, userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, remark: opForm.remark || '工单', institutionId: scope.current.institutionId }).then((r) => ({ code: r.code, message: r.message })));
+}
+async function doApprove(r: BalanceRequestDTO): Promise<void> {
+  await runOp(() => approveRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
+}
+function doReject(r: BalanceRequestDTO): void {
+  void runOp(() => rejectRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
+}
+function doCancelReq(r: BalanceRequestDTO): void {
+  void runOp(() => cancelRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
+}
+
+/* ================= 操作按钮区 ================= */
+const ops = [
+  { label: '充值', fn: doRecharge },
+  { label: '消费', fn: doExpenditure },
+  { label: '退费', fn: doRefund },
+  { label: '自定义退费', fn: doCustomRefund },
+  { label: '坏账', fn: doBadDebt },
+  { label: '清零', fn: doClear },
+  { label: 'spend 扣减', fn: doSpend },
+] as const;
+</script>
+
+<template>
+  <div class="fee-page">
+    <!-- ① 作用户 + 校区 -->
+    <div class="fee-page__bar">
+      <el-select v-model="selectedUserId" placeholder="选择账户" class="fee-page__select">
+        <el-option v-for="m in memberOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+      </el-select>
+      <el-select v-model="selectedCampusId" placeholder="选择校区" class="fee-page__select">
+        <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+      </el-select>
+      <el-button @click="loadDashboard">刷新</el-button>
+    </div>
+
+    <!-- ② 仪表盘 -->
+    <el-row v-if="dash" :gutter="12" class="fee-page__dash">
+      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.balance)" :precision="2" prefix="¥" title="账户余额" /></el-card></el-col>
+      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.totalIncome)" :precision="2" prefix="¥" title="累计充值" /></el-card></el-col>
+      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.totalExpenditure)" :precision="2" prefix="¥" title="累计支出" /></el-card></el-col>
+      <el-col :span="4"><el-card shadow="never"><el-statistic :value="parseFloat(dash.specialExpenses)" :precision="2" prefix="¥" title="特殊费用" /></el-card></el-col>
+      <el-col :span="8">
+        <el-card shadow="never">
+          <template #header>校区余额分布</template>
+          <div v-for="d in dash.balanceDistribution" :key="d.campusId" class="fee-page__dist">
+            <span>{{ campusName(d.campusId) }}</span>
+            <span>¥ {{ d.balance }}</span>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- ③ 页签 -->
+    <el-tabs class="fee-page__tabs">
+      <!-- 流水 -->
+      <el-tab-pane label="资金流水">
+        <ListPage
+          v-model:page="logPage"
+          v-model:page-size="logPageSize"
+          :rows="logs"
+          :total="logTotal"
+          :loading="logLoading"
+          :error="logError"
+          :columns="logColumns"
+          @search="onLogSearch"
+        >
+          <template #toolbar>
+            <el-select v-model="logFilters.type" placeholder="类型" clearable class="toolbar-select">
+              <el-option v-for="(t, k) in typeText" :key="k" :value="Number(k)" :label="t" />
+            </el-select>
+          </template>
+        </ListPage>
+      </el-tab-pane>
+
+      <!-- 账户操作 -->
+      <el-tab-pane label="账户操作">
+        <div class="fee-page__ops">
+          <div class="fee-page__op-form">
+            <el-input v-model="opForm.money" placeholder="金额(如 100.00)" style="width: 160px" />
+            <el-input v-model="opForm.remark" placeholder="备注(工单用)" style="width: 200px" />
+          </div>
+          <div class="fee-page__op-btns">
+            <el-button v-for="op in ops" :key="op.label" type="primary" :disabled="!selectedUserId" :loading="busy" @click="op.fn">
+              {{ op.label }}
+            </el-button>
+            <div class="fee-page__transfer">
+              <el-select v-model="transferForm.fromCampus" placeholder="转出校区" style="width: 140px">
+                <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+              </el-select>
+              <el-select v-model="transferForm.toCampus" placeholder="转入校区" style="width: 140px">
+                <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+              </el-select>
+              <el-input v-model="transferForm.money" placeholder="转结金额" style="width: 120px" />
+              <el-button type="warning" :disabled="!selectedUserId" :loading="busy" @click="doTransfer">转结</el-button>
+              <el-button type="info" :disabled="!selectedUserId" :loading="busy" @click="doCreateRequest">建资金工单</el-button>
+            </div>
+          </div>
+        </div>
+      </el-tab-pane>
+
+      <!-- 资金工单 -->
+      <el-tab-pane label="资金工单">
+        <ListPage
+          v-model:page="reqPage"
+          v-model:page-size="reqPageSize"
+          :rows="reqs"
+          :total="reqTotal"
+          :loading="reqLoading"
+          :error="reqError"
+          :columns="reqColumns"
+          @search="onReqSearch"
+        >
+          <template #toolbar>
+            <el-select v-model="reqFilters.approved" placeholder="状态" clearable class="toolbar-select">
+              <el-option value="0" label="待审" />
+              <el-option value="1" label="已通过" />
+              <el-option value="-1" label="已驳回" />
+              <el-option value="-2" label="已撤销" />
+            </el-select>
+          </template>
+        </ListPage>
+        <div class="fee-page__req-actions">
+          <div v-for="r in reqs" :key="r.id" class="fee-page__req-row">
+            <span class="fee-page__req-no">{{ r.bizNo }}</span>
+            <el-button size="small" type="primary" :disabled="r.approved !== 0" :loading="busy" @click="doApprove(r)">通过</el-button>
+            <el-button size="small" type="warning" :disabled="r.approved !== 0" :loading="busy" @click="doReject(r)">驳回</el-button>
+            <el-button size="small" :disabled="r.approved !== 0" :loading="busy" @click="doCancelReq(r)">撤销</el-button>
+          </div>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<style scoped>
+.fee-page {
+  width: 100%;
+}
+.fee-page__bar {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.fee-page__select {
+  width: 220px;
+}
+.fee-page__dash {
+  margin-bottom: 12px;
+}
+.fee-page__dist {
+  display: flex;
+  justify-content: space-between;
+  padding: 4px 0;
+  font-size: var(--app-font-md);
+}
+.fee-page__tabs {
+  margin-top: 4px;
+}
+.fee-page__ops {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.fee-page__op-form {
+  display: flex;
+  gap: 12px;
+}
+.fee-page__op-btns {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.fee-page__transfer {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  margin-top: 8px;
+}
+.fee-page__req-actions {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.fee-page__req-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.fee-page__req-no {
+  font-size: var(--app-font-md);
+  color: var(--app-text-secondary, #8a8f9c);
+  min-width: 140px;
+}
+</style>

+ 63 - 0
src/views/academic/schedule/index.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+/**
+ * 排班管理页(views/academic/schedule/index.vue)—— 课程/排班列表(菜单「排班管理」,端点 44)。
+ * useListPage + ListPage;作用域从 useScope 取。
+ */
+import { listCourses, type CourseDTO } from '@/api/academic/course';
+import ListPage from '@/components/ListPage.vue';
+import type { ListColumn } from '@/components/list-column';
+import { useListPage } from '@/composables/use-list-page';
+import { useScope } from '@/store/scope';
+
+const scope = useScope();
+const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
+
+const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
+  fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+});
+
+const statusText: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已结束' };
+const columns: ListColumn<CourseDTO>[] = [
+  { prop: 'id', label: '课程 id', minWidth: 90 },
+  { prop: 'name', label: '课程名称', minWidth: 160 },
+  { prop: 'classify', label: '学科', minWidth: 100 },
+  { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => statusText[r.status] ?? String(r.status) },
+  { prop: 'recordCount', label: '排班数', minWidth: 90 },
+];
+
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+onMounted(() => {
+  void fetchList();
+});
+</script>
+
+<template>
+  <div class="schedule-page">
+    <ListPage
+      v-model:page="page"
+      v-model:page-size="pageSize"
+      :rows="rows"
+      :total="total"
+      :loading="loading"
+      :error="error"
+      :columns="columns"
+      @search="onSearch"
+    >
+      <template #toolbar>
+        <el-input v-model="filters.keyword" placeholder="搜索课程名称" clearable class="toolbar-input" />
+        <el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
+          <el-option v-for="(t, k) in statusText" :key="k" :value="Number(k)" :label="t" />
+        </el-select>
+      </template>
+    </ListPage>
+  </div>
+</template>
+
+<style scoped>
+.schedule-page {
+  width: 100%;
+}
+</style>

+ 46 - 0
src/views/common/placeholder.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+/**
+ * placeholder.vue —— 功能建设中占位页(教学/系统域暂未纳入 mock 范围)。
+ *
+ * 为何占位:这些菜单项(题库/练习分析/用户角色/任务监控/操作日志)属教学/系统域,
+ *   尚未落地 mock 端点与后端契约;为避免菜单点击后打未覆盖端点弹 "mock 未覆盖" toast,
+ *   占位页**不发任何请求**,仅静态展示"建设中"。后续对应域补 mock 端点后替换本页。
+ *
+ * 入参:title(页签名)/ desc(可选说明)。
+ */
+defineProps<{
+  /** 页面标题(中文) */
+  title: string;
+  /** 说明文案(可选) */
+  desc?: string;
+}>();
+</script>
+
+<template>
+  <div class="placeholder">
+    <el-empty :description="desc || '该模块功能建设中,敬请期待'">
+      <template #image>
+        <div class="placeholder__mark">{{ title.slice(0, 2) }}</div>
+      </template>
+    </el-empty>
+  </div>
+</template>
+
+<style scoped>
+.placeholder {
+  width: 100%;
+  padding: 48px 0;
+}
+.placeholder__mark {
+  width: 64px;
+  height: 64px;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  font-weight: 600;
+  color: #fff;
+  background: var(--app-brand-navy, #1e2a54);
+}
+</style>

+ 184 - 68
src/views/login/index.vue

@@ -247,97 +247,205 @@ onUnmounted(() => {
 </script>
 
 <template>
-  <div class="login-page">
-    <el-card class="login-card">
-      <template #header>
-        <span class="login-title">统一管理平台</span>
-      </template>
+  <div class="login">
+    <!-- 左:品牌区(墨蓝;窄屏隐藏)—— 对齐原型 .login-brand -->
+    <aside class="login-brand">
+      <div class="brand-logo">
+        <div class="brand-mark">◍</div>
+        <div class="brand-text"><b>源思维 · 统一后台</b><span>教务 × 教学 · 多机构多校区</span></div>
+      </div>
+      <p class="login-slogan">一个账号,<br>跨校区、跨角色<em>无缝切换</em>。</p>
+      <div class="login-foot">© 2026 源思维 · 登录即代表同意《管理端使用协议》</div>
+    </aside>
 
-      <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-error" />
+    <!-- 右:登录面板 -->
+    <div class="login-panel">
+      <div class="login-card">
+        <h2 class="login-card__title">登录</h2>
+        <p class="login-card__sub">输入机构账号或使用微信扫码进入管理端</p>
 
-      <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
-      <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
-        <!-- 账号密码 -->
-        <el-tab-pane label="账号密码" name="password">
-          <el-form :model="form" :rules="rules" label-width="72px" @submit.prevent="handleSubmit">
-            <el-form-item label="用户名" prop="username">
-              <el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
-            </el-form-item>
-            <el-form-item label="密码" prop="password">
-              <el-input
-                v-model="form.password"
-                type="password"
-                placeholder="请输入密码"
-                autocomplete="current-password"
-                show-password
-              />
-            </el-form-item>
-            <el-form-item>
-              <el-button type="primary" :loading="submitting" native-type="submit" class="login-submit">
-                登 录
-              </el-button>
-            </el-form-item>
-          </el-form>
-        </el-tab-pane>
+        <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-err" />
+
+        <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
+        <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
+          <!-- 账号密码(两步:① /login 取票据 → ② /issue 签 token;单条作用域自动②,多条进选择页) -->
+          <el-tab-pane label="账号密码" name="password">
+            <el-form :model="form" :rules="rules" label-position="top" @submit.prevent="handleSubmit">
+              <el-form-item label="账号(用户名或手机号)" prop="username">
+                <el-input v-model="form.username" placeholder="如 admin 或 138****8888" autocomplete="username" />
+              </el-form-item>
+              <el-form-item label="密码" prop="password">
+                <el-input
+                  v-model="form.password"
+                  type="password"
+                  placeholder="输入密码"
+                  autocomplete="current-password"
+                  show-password
+                />
+              </el-form-item>
+              <el-form-item>
+                <el-button type="primary" :loading="submitting" native-type="submit" class="login-cta">
+                  登 录
+                </el-button>
+              </el-form-item>
+            </el-form>
+            <p class="login-tip">登录成功后将进入机构 / 校区作用域选择</p>
+          </el-tab-pane>
 
-        <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
-        <el-tab-pane label="微信扫码" name="wechat">
-          <div class="login-wechat">
-            <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
-            <div v-else class="login-wechat__placeholder">
+          <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
+          <el-tab-pane label="微信扫码" name="wechat">
+            <div class="login-wechat">
+              <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
+              <div v-else class="login-wechat__placeholder">
+                <el-button :loading="qrcodeLoading" class="login-wechat__btn" @click="getQrcode">
+                  {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
+                </el-button>
+              </div>
               <el-button
+                v-if="qrDataUrl"
+                link
+                type="primary"
                 :loading="qrcodeLoading"
-                class="login-wechat__btn"
+                class="login-wechat__refresh"
                 @click="getQrcode"
               >
-                {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
+                重新获取二维码
               </el-button>
+              <p class="login-wechat__tip">
+                {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
+              </p>
             </div>
-            <el-button
-              v-if="qrDataUrl"
-              link
-              type="primary"
-              :loading="qrcodeLoading"
-              class="login-wechat__refresh"
-              @click="getQrcode"
-            >
-              重新获取二维码
-            </el-button>
-            <p class="login-wechat__tip">
-              {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
-            </p>
-          </div>
-        </el-tab-pane>
-      </el-tabs>
-    </el-card>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+    </div>
   </div>
 </template>
 
 <style scoped>
-.login-page {
+/* 对齐原型 .login(左墨蓝品牌区 + 右白登录面板;窄屏只留面板) */
+.login {
   min-height: 100vh;
+  display: grid;
+  grid-template-columns: 1fr 460px;
+  background: var(--app-color-page);
+}
+.login-brand {
+  background: var(--app-brand-navy);
+  color: var(--app-side-text);
+  padding: 48px 56px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  position: relative;
+  overflow: hidden;
+}
+.login-brand::after {
+  content: '';
+  position: absolute;
+  right: -120px;
+  top: -120px;
+  width: 420px;
+  height: 420px;
+  border-radius: 50%;
+  background: radial-gradient(circle at center, rgba(75, 184, 200, 0.35), transparent 65%);
+}
+.brand-logo {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  position: relative;
+  z-index: 1;
+}
+.brand-mark {
+  width: 44px;
+  height: 44px;
+  border-radius: 10px;
+  background: var(--app-brand-teal);
+  display: grid;
+  place-items: center;
+  font-size: 22px;
+  box-shadow: 0 0 0 4px rgba(75, 184, 200, 0.25);
+}
+.brand-text b {
+  font-size: 17px;
+  font-weight: 600;
+  color: #fff;
+}
+.brand-text span {
+  display: block;
+  font-size: 12px;
+  color: var(--app-side-dim);
+}
+.login-slogan {
+  font-size: 26px;
+  font-weight: 600;
+  line-height: 1.5;
+  max-width: 420px;
+  color: var(--app-side-text);
+  position: relative;
+  z-index: 1;
+  margin: 0;
+}
+.login-slogan em {
+  font-style: normal;
+  color: var(--app-brand-yellow);
+}
+.login-foot {
+  font-size: 12px;
+  color: var(--app-side-dim);
+  position: relative;
+  z-index: 1;
+}
+.login-panel {
+  background: var(--app-color-card);
   display: flex;
   align-items: center;
   justify-content: center;
-  background: var(--app-bg-page);
+  padding: 40px;
 }
 .login-card {
-  width: 400px;
+  width: 100%;
+  max-width: 340px;
 }
-.login-title {
-  font-size: var(--app-font-lg);
+.login-card__title {
+  font-size: 20px;
   font-weight: 600;
-  color: var(--app-text-primary);
+  margin: 0 0 4px;
+  color: var(--app-color-text);
+}
+.login-card__sub {
+  color: var(--app-color-text-2);
+  font-size: 13px;
+  margin: 0 0 24px;
 }
-.login-error {
-  margin-bottom: var(--app-spacing-md);
+.login-err {
+  margin-bottom: 14px;
 }
-.login-submit {
+/* EP 表单项:标签置上 + 次要色 + 常规字重(贴原型 .field label) */
+.login-card :deep(.el-form-item__label) {
+  font-size: 13px;
+  color: var(--app-color-text-2);
+  font-weight: 400;
+  line-height: 1.4;
+  padding-bottom: 4px;
+}
+.login-card :deep(.el-form-item) {
+  margin-bottom: 16px;
+}
+.login-cta {
   width: 100%;
+  margin-top: 6px;
+}
+.login-tip {
+  font-size: 12px;
+  color: var(--app-color-text-3);
+  margin: 12px 0 0;
 }
 .login-tabs {
-  margin-top: var(--app-spacing-sm);
+  margin-top: 4px;
 }
+/* 微信扫码区(占位 / 二维码布局保持) */
 .login-wechat {
   display: flex;
   flex-direction: column;
@@ -347,7 +455,7 @@ onUnmounted(() => {
 .login-wechat__qrcode {
   width: 200px;
   height: 200px;
-  border: 1px solid var(--app-bg-page);
+  border: 1px solid var(--app-color-border);
   border-radius: var(--app-radius-md);
 }
 .login-wechat__placeholder {
@@ -356,8 +464,8 @@ onUnmounted(() => {
   display: flex;
   align-items: center;
   justify-content: center;
-  background: var(--app-bg-page);
-  color: var(--app-text-secondary);
+  background: var(--app-color-page);
+  color: var(--app-color-text-2);
   font-size: var(--app-font-md);
   border-radius: var(--app-radius-md);
 }
@@ -370,6 +478,14 @@ onUnmounted(() => {
 .login-wechat__tip {
   margin: var(--app-spacing-sm) 0 0;
   font-size: var(--app-font-sm);
-  color: var(--app-text-secondary);
+  color: var(--app-color-text-2);
+}
+@media (max-width: 1080px) {
+  .login {
+    grid-template-columns: 1fr;
+  }
+  .login-brand {
+    display: none;
+  }
 }
 </style>

+ 68 - 0
src/views/org/campus/list.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+/**
+ * 校区列表页(views/org/campus/list.vue)—— useListPage + ListPage 统一分页(菜单「机构/校区」)。
+ *
+ * 链路:本页面 ── fetcher(并入 scope.toQuery()) ──> listCampuses ──> useListPage 解 data.{list,total}
+ * 作用域纪律:institutionId/campusId 只从 useScope().toQuery() 取,页面不硬编码。
+ */
+import { listCampuses, type CampusDTO } from '@/api/org/campus';
+import ListPage from '@/components/ListPage.vue';
+import type { ListColumn } from '@/components/list-column';
+import { useListPage } from '@/composables/use-list-page';
+import { useScope } from '@/store/scope';
+import { formatDateTime, formatStatus } from '@/utils/format';
+
+const scope = useScope();
+
+/** 筛选条件(页面级状态;工具区插槽控件绑定) */
+const filters = reactive<{ keyword: string }>({ keyword: '' });
+
+/** 列表状态(useListPage 收口;fetcher 内并入作用域) */
+const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<CampusDTO>({
+  fetcher: (p, ps) => listCampuses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+});
+
+/** 列定义(数据驱动;formatter 返回字符串) */
+const columns: ListColumn<CampusDTO>[] = [
+  { prop: 'campusName', label: '校区名称', minWidth: 180 },
+  { prop: 'institutionId', label: '机构 id', minWidth: 100 },
+  { prop: 'status', label: '状态', minWidth: 100, formatter: (row) => formatStatus(row.status) },
+  { prop: 'createdAt', label: '创建时间', minWidth: 160, formatter: (row) => formatDateTime(row.createdAt) },
+];
+
+/** 工具区"搜索"按钮 → 重置第 1 页重载 */
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+
+/** 初始加载(作用域已由守卫链保证就绪) */
+onMounted(() => {
+  void fetchList();
+});
+</script>
+
+<template>
+  <div class="campus-list">
+    <ListPage
+      v-model:page="page"
+      v-model:page-size="pageSize"
+      :rows="rows"
+      :total="total"
+      :loading="loading"
+      :error="error"
+      :columns="columns"
+      @search="onSearch"
+    >
+      <template #toolbar>
+        <el-input v-model="filters.keyword" placeholder="搜索校区名称" clearable class="toolbar-input" />
+      </template>
+    </ListPage>
+  </div>
+</template>
+
+<style scoped>
+.campus-list {
+  width: 100%;
+}
+</style>

+ 75 - 0
src/views/org/member/list.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+/**
+ * 成员/班级列表页(views/org/member/list.vue)—— useListPage + ListPage(菜单「成员/班级」)。
+ * 作用域纪律:institutionId/campusId 只从 useScope().toQuery() 取。
+ */
+import { listMembers, type MemberDTO } from '@/api/org/member';
+import ListPage from '@/components/ListPage.vue';
+import type { ListColumn } from '@/components/list-column';
+import { useListPage } from '@/composables/use-list-page';
+import { useScope } from '@/store/scope';
+import { formatDateTime, formatStatus } from '@/utils/format';
+
+const scope = useScope();
+
+/** 筛选条件(页面级状态) */
+const filters = reactive<{ keyword: string; personType: string }>({ keyword: '', personType: '' });
+
+/** 列表状态(useListPage 收口;fetcher 并入作用域) */
+const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<MemberDTO>({
+  fetcher: (p, ps) => listMembers({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+});
+
+/** 人员类型中文映射 */
+const personTypeText: Record<string, string> = { staff: '教职工', student: '学生', parent: '家长' };
+
+/** 列定义 */
+const columns: ListColumn<MemberDTO>[] = [
+  { prop: 'realName', label: '姓名', minWidth: 120 },
+  { prop: 'personType', label: '类型', minWidth: 90, formatter: (row) => personTypeText[row.personType] ?? row.personType },
+  { prop: 'phone', label: '手机号', minWidth: 130 },
+  { prop: 'role', label: '角色', minWidth: 120 },
+  { prop: 'campusId', label: '校区', minWidth: 80, formatter: (row) => (row.campusId > 0 ? `#${row.campusId}` : '机构级') },
+  { prop: 'status', label: '状态', minWidth: 90, formatter: (row) => formatStatus(row.status) },
+  { prop: 'createdAt', label: '创建时间', minWidth: 150, formatter: (row) => formatDateTime(row.createdAt) },
+];
+
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+
+onMounted(() => {
+  void fetchList();
+});
+</script>
+
+<template>
+  <div class="member-list">
+    <ListPage
+      v-model:page="page"
+      v-model:page-size="pageSize"
+      :rows="rows"
+      :total="total"
+      :loading="loading"
+      :error="error"
+      :columns="columns"
+      @search="onSearch"
+    >
+      <template #toolbar>
+        <el-input v-model="filters.keyword" placeholder="搜索姓名" clearable class="toolbar-input" />
+        <el-select v-model="filters.personType" placeholder="人员类型" clearable class="toolbar-select">
+          <el-option value="staff" label="教职工" />
+          <el-option value="student" label="学生" />
+          <el-option value="parent" label="家长" />
+        </el-select>
+      </template>
+    </ListPage>
+  </div>
+</template>
+
+<style scoped>
+.member-list {
+  width: 100%;
+}
+</style>

+ 243 - 68
src/views/org/scope-select.vue

@@ -7,14 +7,15 @@
  *       选项来自 login store 的 pendingMemberships(登录① 响应下发,非接口二次拉取——
  *       票据一次性,重拉机构列表既无权限(未签 token)也多余)。
  *
+ * 交互(对齐原型 scr-scope):墨蓝全屏 + 白卡 + 欢迎语 + 步骤条(登录→选择作用域→进入工作台)
+ *   + **卡片式**作用域单选(非下拉框)+ 返回登录/进入工作台双按钮。
+ *   **默认选中上次选择**:进入本页时按 login.lastScope()(localStorage 记住的上次作用域)
+ *   匹配对应 membership 默认选中并打"上次"角标;无记录则默认首项。
+ *
  * 状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
  *   进入本页时 pendingTicket/pendingMemberships 已由登录页 applyStep1 暂存;
- *   选定 (institutionId, campusId) → ② issue → applyLogin(写 token + scope)→ 跳机构列表。
+ *   选定 (institutionId, campusId) → ② issue → applyLogin(写 token + scope + 记住作用域)→ 跳机构列表。
  *   票据 5min 有效、单次消费;本页未签发前刷新会丢失过渡态,需回登录页重走①。
- *
- * 典型使用(由登录页/守卫自动进入,页面不主动跳转):
- *
- *   handleConfirm → issue(pendingTicket, 选定作用域) → loginStore.applyLogin → 跳 /org/institutions
  */
 import { issue } from '@/api/auth/login';
 import { useLogin } from '@/store/login';
@@ -26,47 +27,64 @@ const router = useRouter();
 const submitting = ref(false);
 /** 选定中的成员关系 id(pendingMemberships 内唯一定位;0 = 未选) */
 const selectedMembershipId = ref(0);
+/** 默认选中是否来自"上次选择"(用于"上次"角标展示) */
+const isLastSelected = ref(false);
 /** ② 失败文案(后端 message 直显;票据失效 1001 / 越权 1003) */
 const errorMsg = ref('');
 
+/** 欢迎语(登录① 已暂存 realName;缺失时兜底"同学"——原型用"李明",此处用真实姓名) */
+const welcomeName = computed(() => login.realName || '用户');
+
+/** 卡片式选项数据(由 pendingMemberships 派生;展示用文案,提交仍用 membershipId 定位) */
+const options = computed(() =>
+  login.pendingMemberships.map((m) => ({
+    membershipId: m.membershipId,
+    title: m.campusId > 0 ? `机构 #${m.institutionId} · 校区 #${m.campusId}` : `机构 #${m.institutionId}(集团)`,
+    role: m.role,
+    dataScope: m.campusId > 0 ? '本校区' : '全机构',
+  })),
+);
+
+/** 进入本页:默认选中"上次选择"的 membership;无记录则默认首项。 */
+onMounted(() => {
+  const list = login.pendingMemberships;
+  if (list.length === 0) return;
+  const last = login.lastScope();
+  if (last) {
+    const hit = list.find((m) => m.institutionId === last.institutionId && m.campusId === last.campusId);
+    if (hit) {
+      selectedMembershipId.value = hit.membershipId;
+      isLastSelected.value = true;
+      return;
+    }
+  }
+  // 无记忆:默认首项
+  selectedMembershipId.value = list[0].membershipId;
+});
+
 /**
- * 确认选择(handle 前缀:用户触发提交类)。
- *
- * 入参:无(读 selectedMembershipId 当前值,定位 pendingMemberships 内对应作用域)。
+ * 确认进入(② issue 签发)。
  *
- * 出参:
- *   - 无返回值;成功后 ② 签发 + applyLogin + 跳机构列表,失败写 errorMsg。
- * 异常场景:
- *   - pendingTicket 为空(过渡态丢失,如刷新):提示"登录已失效,请重新登录"并跳登录页;
- *   - 未选作用域(selectedMembershipId=0):按钮 disabled,不触发;
- *   - ② code=1001(票据失效/已消费/过期):errorMsg 直显,引导重走①;
- *   - ② code=1003(越权/归属失效):errorMsg 直显;
- *   - 网络层 reject:errorMsg = "网络连接异常"。
+ * 出参:成功后 ② 签发 + applyLogin(含记住作用域)+ 跳机构列表,失败写 errorMsg。
+ * 异常:pendingTicket 空(过渡态丢失)→ 跳登录;未选 → 不触发;1001/1003 直显。
  */
 async function handleConfirm(): Promise<void> {
   if (submitting.value) return;
   const m = login.pendingMemberships.find((x) => x.membershipId === selectedMembershipId.value);
-  if (!m) return; // 未选:按钮 disabled,双保险
+  if (!m) return; // 未选:双保险
   if (!login.pendingTicket) {
-    // 过渡态丢失(典型:刷新后票据未持久化):引导回登录页重走①
     await router.push('/login');
     return;
   }
   errorMsg.value = '';
   submitting.value = true;
   try {
-    const pair = await issue({
-      loginTicket: login.pendingTicket,
-      institutionId: m.institutionId,
-      campusId: m.campusId,
-    });
+    const pair = await issue({ loginTicket: login.pendingTicket, institutionId: m.institutionId, campusId: m.campusId });
     if (pair.code === 200 && pair.data) {
-      login.applyLogin(pair.data); // 写 token + roles/permissions/menuTree + scope
+      login.applyLogin(pair.data); // 写 token + roles/permissions/menuTree + scope + 记住作用域
       await router.push('/org/institutions');
       return;
     }
-    // 1001 票据失效 / 1003 越权:直显后端 message(http 拦截器对 1001 已会尝试 refresh+重放,
-    // 但此处票据为登录过渡态非 refresh 链,失败即直显)
     errorMsg.value = pair.message || '登录失败';
   } catch {
     errorMsg.value = '网络连接异常';
@@ -75,7 +93,7 @@ async function handleConfirm(): Promise<void> {
   }
 }
 
-/** 登出入口(页面右上角;调 login store 统一登出流程,清过渡态 + token + scope) */
+/** 登出入口(调 login store 统一登出流程;不清除"上次作用域"偏好) */
 async function handleLogout(): Promise<void> {
   await login.doLogout();
   await router.push('/login');
@@ -84,13 +102,22 @@ async function handleLogout(): Promise<void> {
 
 <template>
   <div class="scope-page">
-    <el-card class="scope-card">
-      <template #header>
-        <div class="scope-header">
-          <span class="scope-title">选择机构</span>
-          <el-button link type="primary" @click="handleLogout">退出登录</el-button>
+    <div class="scope-card">
+      <!-- 欢迎区(对齐原型:brand-mark + 欢迎语 + 副标题) -->
+      <div class="scope-welcome">
+        <div class="brand-mark">◍</div>
+        <div class="scope-welcome__text">
+          <b>欢迎回来,{{ welcomeName }}</b>
+          <div class="scope-welcome__sub">请选择进入的机构 / 校区与角色</div>
         </div>
-      </template>
+      </div>
+
+      <!-- 步骤条(登录→选择作用域→进入工作台) -->
+      <div class="steps">
+        <div class="step on">登录</div>
+        <div class="step on">选择作用域</div>
+        <div class="step">进入工作台</div>
+      </div>
 
       <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="scope-error" />
       <el-alert
@@ -102,55 +129,203 @@ async function handleLogout(): Promise<void> {
         class="scope-error"
       />
 
-      <el-form label-width="72px" v-if="login.pendingMemberships.length > 0">
-        <el-form-item label="机构/校区">
-          <el-select
-            v-model="selectedMembershipId"
-            placeholder="请选择"
-            style="width: 320px"
-            :disabled="login.pendingMemberships.length === 1"
-          >
-            <!-- 选项来自登录① 下发的 pendingMemberships(非硬编码、非二次接口拉取) -->
-            <el-option
-              v-for="m in login.pendingMemberships"
-              :key="m.membershipId"
-              :value="m.membershipId"
-              :label="`${m.role} · 机构#${m.institutionId}${m.campusId ? ' · 校区#' + m.campusId : ''}`"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" :loading="submitting" :disabled="!selectedMembershipId" @click="handleConfirm">
-            进入
-          </el-button>
-        </el-form-item>
-      </el-form>
-    </el-card>
+      <!-- 卡片式作用域单选(对齐原型 .scope-opt;非下拉框) -->
+      <div v-if="login.pendingMemberships.length > 0" class="scope-list">
+        <label
+          v-for="opt in options"
+          :key="opt.membershipId"
+          class="scope-opt"
+          :class="{ on: opt.membershipId === selectedMembershipId }"
+        >
+          <input
+            type="radio"
+            name="scope"
+            class="scope-opt__radio"
+            :checked="opt.membershipId === selectedMembershipId"
+            @change="selectedMembershipId = opt.membershipId"
+          />
+          <div class="scope-opt__text">
+            <b>{{ opt.title }}</b>
+            <div class="scope-opt__meta">角色:{{ opt.role }} · 数据范围:{{ opt.dataScope }}</div>
+          </div>
+          <span v-if="isLastSelected && opt.membershipId === selectedMembershipId" class="badge badge-teal">上次</span>
+        </label>
+      </div>
+
+      <!-- 双按钮(返回登录 / 进入工作台) -->
+      <div class="scope-actions">
+        <el-button class="scope-btn scope-btn--ghost" @click="handleLogout">返回登录</el-button>
+        <el-button type="primary" class="scope-btn" :loading="submitting" :disabled="!selectedMembershipId" @click="handleConfirm">
+          进入工作台
+        </el-button>
+      </div>
+    </div>
   </div>
 </template>
 
 <style scoped>
+/* 对齐原型 scr-scope:墨蓝全屏 + 居中白卡 */
 .scope-page {
   min-height: 100vh;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: var(--app-bg-page);
+  display: grid;
+  place-items: center;
+  background: var(--app-brand-navy);
+  padding: 24px;
 }
 .scope-card {
-  width: 480px;
+  width: 460px;
+  max-width: 100%;
+  background: var(--app-color-card);
+  border: 1px solid var(--app-color-border);
+  border-radius: 8px;
+  padding: 24px;
+  box-shadow: var(--app-shadow-float);
 }
-.scope-header {
+.scope-welcome {
   display: flex;
-  justify-content: space-between;
   align-items: center;
+  gap: 12px;
+  margin-bottom: 6px;
+}
+.brand-mark {
+  width: 38px;
+  height: 38px;
+  border-radius: 8px;
+  background: var(--app-brand-teal);
+  display: grid;
+  place-items: center;
+  font-size: 19px;
+  flex: 0 0 auto;
+  box-shadow: 0 0 0 4px rgba(75, 184, 200, 0.25);
+}
+.scope-welcome__text b {
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--app-color-text);
+}
+.scope-welcome__sub {
+  font-size: 12px;
+  color: var(--app-color-text-3);
+  margin-top: 2px;
+}
+
+/* 步骤条(对齐原型 .steps) */
+.steps {
+  display: flex;
+  margin: 14px 0 4px;
+}
+.step {
+  flex: 1;
+  text-align: center;
+  position: relative;
+  font-size: 12px;
+  color: var(--app-color-text-3);
 }
-.scope-title {
-  font-size: var(--app-font-lg);
+.step::before {
+  content: '';
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  background: var(--app-color-border);
+  display: inline-block;
+  margin-bottom: 8px;
+}
+.step.on {
+  color: var(--app-brand-teal-deep);
   font-weight: 600;
-  color: var(--app-text-primary);
 }
+.step.on::before {
+  background: var(--app-brand-teal-deep);
+  box-shadow: 0 0 0 4px var(--app-color-teal-soft);
+}
+.step::after {
+  content: '';
+  position: absolute;
+  top: 6px;
+  left: calc(50% + 14px);
+  right: calc(-50% + 14px);
+  height: 1px;
+  background: var(--app-color-border);
+}
+.step:last-child::after {
+  display: none;
+}
+
 .scope-error {
-  margin-bottom: var(--app-spacing-md);
+  margin: 14px 0;
+}
+
+/* 卡片式作用域选项(对齐原型 .scope-opt) */
+.scope-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 18px 0;
+}
+.scope-opt {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  border: 1px solid var(--app-color-border);
+  border-radius: 8px;
+  padding: 14px 16px;
+  cursor: pointer;
+  transition: border-color 0.15s, background 0.15s;
+}
+.scope-opt:hover {
+  background: var(--app-color-page);
+}
+.scope-opt.on {
+  border-color: var(--app-brand-teal-deep);
+  background: var(--app-color-teal-soft);
+}
+.scope-opt__radio {
+  accent-color: var(--app-brand-teal-deep);
+  width: 16px;
+  height: 16px;
+  flex: 0 0 auto;
+}
+.scope-opt__text {
+  flex: 1;
+  min-width: 0;
+}
+.scope-opt__text b {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--app-color-text);
+}
+.scope-opt__meta {
+  font-size: 12px;
+  color: var(--app-color-text-2);
+  margin-top: 2px;
+}
+.badge {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 4px;
+  padding: 2px 8px;
+  font-size: 12px;
+  line-height: 1.5;
+  flex: 0 0 auto;
+}
+.badge-teal {
+  background: var(--app-color-teal-soft);
+  color: var(--app-color-teal-ink);
+}
+
+/* 双按钮(对齐原型:ghost + primary 等宽) */
+.scope-actions {
+  display: flex;
+  gap: 10px;
+}
+.scope-btn {
+  flex: 1;
+}
+.scope-btn--ghost {
+  color: var(--app-color-text-2);
+  border-color: var(--app-color-border);
+}
+.scope-btn--ghost:hover {
+  background: var(--app-brand-sun);
 }
 </style>

+ 10 - 0
src/views/system/tasks/index.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+/**
+ * 任务监控占位页(views/system/tasks/index.vue)—— 系统域,暂不纳入 mock(菜单「任务监控」)。
+ */
+import Placeholder from '@/views/common/placeholder.vue';
+</script>
+
+<template>
+  <Placeholder title="任务监控" desc="系统域·任务与定时作业监控功能建设中" />
+</template>

+ 10 - 0
src/views/system/users/index.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+/**
+ * 用户/角色占位页(views/system/users/index.vue)—— 系统域,暂不纳入 mock(菜单「用户/角色」)。
+ */
+import Placeholder from '@/views/common/placeholder.vue';
+</script>
+
+<template>
+  <Placeholder title="用户角色" desc="系统域·用户与角色管理功能建设中" />
+</template>

+ 10 - 0
src/views/teaching/analysis/index.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+/**
+ * 练习分析占位页(views/teaching/analysis/index.vue)—— 教学域,暂不纳入 mock(菜单「练习分析」)。
+ */
+import Placeholder from '@/views/common/placeholder.vue';
+</script>
+
+<template>
+  <Placeholder title="练习分析" desc="教学域·练习与错题分析功能建设中" />
+</template>

+ 11 - 0
src/views/teaching/exam/index.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+/**
+ * 题库/组卷占位页(views/teaching/exam/index.vue)—— 教学域,暂不纳入 mock(菜单「题库/组卷」)。
+ * 后续教学域补 mock 端点后替换本页(见 common/placeholder.vue 说明)。
+ */
+import Placeholder from '@/views/common/placeholder.vue';
+</script>
+
+<template>
+  <Placeholder title="题库组卷" desc="教学域·题库与组卷功能建设中" />
+</template>