Parcourir la source

feat(org): 补齐 org 域 mock + 10 业务页 + 费用页操作面(打通业务页 mock)

背景:mock 原只做 academic 58 端点,落地页 /org/institutions 与菜单「机构/校区」
「成员/班级」打 /api/org/*(org 域零 mock)→ 进工作台首屏即 3001「mock 未覆盖」。

- org 域 mock(routes/org.ts,3 端点,注册进 index.ts):institutions/campuses/
  members,复用 data.campuses/users 种子,越界 1004
- 路由:layout children 1→12(org/campus、org/member、academic/fee、academic/
  schedule、academic/activity、academic/divide、teaching/exam、teaching/analysis、
  system/users、system/tasks、system/logs),菜单 path 全对齐
- 费用页(views/academic/fee/index.vue,对齐旧 PHP 财务可操作面):账户+校区选择、
  仪表盘(端点21 el-statistic)、流水(端点11 分页)、8 账户操作(充值/消费/退费/自定义
  退费/坏账/清零/spend+转结,敏感操作二次确认+requestId 幂等)、工单 4 操作(通过/
  驳回/撤销)
- 教学/系统域 5 页做占位(common/placeholder.vue,不发请求避免 toast)
- 新增 api 封装(一资源一文件):api/org/{campus,member}、api/academic/{finance,
  activity,divide-into,course}

门禁:vue-tsc 0 错 / build 成功 / dist mock 特征串 0 泄漏 / dev 7 新模块转换 200
Q il y a 8 heures
Parent
commit
fdb94de38c

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

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

@@ -19,6 +19,7 @@
  */
 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';
@@ -38,6 +39,7 @@ import type { AxiosInstance } from 'axios';
 export function registerMock(http: AxiosInstance): void {
   setRoutes([
     ...authRoutes,
+    ...orgRoutes,
     ...financeRoutes,
     ...activityRoutes,
     ...divideIntoRoutes,

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

+ 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: '操作日志' },
+      },
     ],
   },
   {

+ 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>

+ 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>

+ 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>