Ver código fonte

align: 活动域 type/status 语义 + 分账域 B-1/B-3/B-4 按后端定案落地

活动域(P0 四条):
- data.ts: type 注释 2充值赠送/3代金券/4老带新,status 注释 0取消/1报名/2参加/3报课/4过期;5 种子重映射
- activities.ts: 整体重写 16 端点(roleOfAuth→requireRole、type/status 白名单、端点25整体替换、端点26无money+status归1、端点31/32/35 补 1003/3109/status=2)
- activity.ts: signActivity 去 money 加 status?
- activity/index.vue: typeText/statusText 改、el-segmented 标签改、删报名金额输入框、推荐人 v-if type===4、去 moneyPlain

分账域(B-1/B-3/B-4/P1-1):
- divide-into.ts DTO: createDivideLog R<{logged:boolean}>(B-1)、DivideLogQuery from/to 右开注释(B-3)、DivideNodeDTO 加 layer + getDivideTree 返回加 nodeCount/rootCount(P1-1)
- mock divide-into.ts: 端点41 logId→logged(B-1)、6 端点去 checkScope(B-4 分账域无机构维度)、端点43 to 右开(B-3)、端点38 补 layer/nodeCount/rootCount(P1-1)
- data.ts: DivideNode 接口加 layer,种子补 layer=1/2/3

B-2 保持不动(mock MAX_DIVIDE_DEPTH=3,等后端加校验/业务拍板)
门禁四件全绿(vue-tsc/eslint/build/dist grep)
Q 1 hora atrás
pai
commit
2c5d42caa6

+ 9 - 4
src/api/academic/activity.ts

@@ -18,7 +18,7 @@ export interface ActivityDTO {
   institutionId: number;
   /** 校区 id(0 = 机构级) */
   campusId: number;
-  /** 活动类型编码(2 充值赠送 / 3 老带新 / 4 特殊费用) */
+  /** 活动类型编码(2 充值赠送 / 3 代金券 / 4 老带新;Go models_activity.go ActivityTypeRecharge/Voucher/Recommend) */
   type: number;
   /** 活动名称 */
   name: string;
@@ -71,7 +71,7 @@ export interface ActivitySignDTO {
   activityId: number;
   /** 报名用户 id(canonical ULID) */
   userId: string;
-  /** 状态数字码(1 有效;-1 已取消,前端映射) */
+  /** 状态数字码(0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期;Go ActivitySignedCancelled=0) */
   status: number;
   /** 推荐人(老带新)用户 id,可空 */
   recommendUserId: string | null;
@@ -81,10 +81,15 @@ export interface ActivitySignDTO {
   createTime: string;
 }
 
-/** 报名(端点 26,POST /api/admin/activities/:id/sign)。入参:activityId + body。出参 code(3105 已报名 / 3106 老带新限一次)。 */
+/**
+ * 报名(端点 26,POST /api/admin/activities/:id/sign)。
+ * 入参仅 userId / status? / recommendUserId? —— **无 money**(报名金额=活动级 money 快照,
+ * 后端 SignInput 无 Money 字段,dump signed_add 5 参;前端不传金额,金额由后端从活动级快照)。
+ * 出参 code(3105 已报名 / 3106 老带新限一次 / 3141 满员)。
+ */
 export function signActivity(
   activityId: number,
-  body: { userId: string; money?: string; recommendUserId?: string },
+  body: { userId: string; status?: number; recommendUserId?: string },
 ): Promise<R<{ signedId: number }>> {
   return http.post<R<{ signedId: number }>>(`/api/admin/activities/${activityId}/sign`, body).then((r) => r.data);
 }

+ 14 - 12
src/api/academic/divide-into.ts

@@ -10,19 +10,21 @@
 import { http } from '@/api/http';
 import type { PageR, R } from '@/types/api';
 
-/** 分账节点 DTO(递归树;与 mock nodeRow 逐字段对齐) */
+/** 分账节点 DTO(递归树;与 mock nodeRow 逐字段对齐;Go divide_tree_service.go DivideTreeNode) */
 export interface DivideNodeDTO {
   /** 节点(用户)id(canonical_user_id ULID 字符串) */
   userId: string;
   /** 真实姓名 */
   realName: string;
-  /** 上级用户 id(根节点为空串/缺省) */
-  parentUserId?: string;
+  /** 上级用户 id(根节点为 null) */
+  parentUserId: string | null;
   /** 分成额度(金额字符串,每次分成金额;0.00 = 该链不分成) */
   quota: string;
   /** 分成累计收入(字符串两位小数) */
   cumulativeIncome: string;
-  /** 子节点(递归;根为顶层数组) */
+  /** 层级(根=1;Go MaxDivideDepth=100,业务约束 3 层) */
+  layer: number;
+  /** 子节点(递归;空为 [],非 null) */
   children: DivideNodeDTO[];
 }
 
@@ -36,9 +38,9 @@ export type DivideTreeQuery = {
   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);
+/** 分账树(端点 38,GET /api/admin/divide-into/tree)。出参:R<{ tree, nodeCount, rootCount }>。 */
+export function getDivideTree(params: DivideTreeQuery): Promise<R<{ tree: DivideNodeDTO[]; nodeCount: number; rootCount: number }>> {
+  return http.get<R<{ tree: DivideNodeDTO[]; nodeCount: number; rootCount: number }>>('/api/admin/divide-into/tree', { params }).then((r) => r.data);
 }
 
 /** 添加下线(端点 39,POST /api/admin/divide-into/members)。新节点默认 quota=0.00。出参 code(3111 层级超限)。 */
@@ -74,7 +76,7 @@ export interface DivideLogDTO {
   createTime: string;
 }
 
-/** 记分账流水(端点 41,POST /api/admin/divide-into/logs)。出参 code(3001 分账人不在树/学生不存在)。 */
+/** 记分账流水(端点 41,POST /api/admin/divide-into/logs)。出参 { logged: true }(Go divide_handler.go:263;dump 不返回 id)。 */
 export function createDivideLog(body: {
   /** 分账人(须在分账树中) */
   findUserId: string;
@@ -84,8 +86,8 @@ export function createDivideLog(body: {
   orderNo: string;
   /** 备注(可空) */
   remark?: string;
-}): Promise<R<{ logId: number }>> {
-  return http.post<R<{ logId: number }>>('/api/admin/divide-into/logs', body).then((r) => r.data);
+}): Promise<R<{ logged: boolean }>> {
+  return http.post<R<{ logged: boolean }>>('/api/admin/divide-into/logs', body).then((r) => r.data);
 }
 
 /** 删分账流水(端点 42,DELETE /api/admin/divide-into/logs)。按 studentId+orderNo 定位。出参 code(3001 不存在)。 */
@@ -103,9 +105,9 @@ export type DivideLogQuery = {
   userId?: string;
   /** 学生 id 筛选(可空) */
   studentId?: string;
-  /** 起始日期(可空,闭区间) */
+  /** 起始日期(可空;右开区间 from <= createTime < to) */
   from?: string;
-  /** 结束日期(可空) */
+  /** 结束日期(可空;右开,即 < to 不含 to 当日 00:00 起) */
   to?: string;
   /** 页码 */
   page?: number;

+ 25 - 19
src/api/mock/data.ts

@@ -355,7 +355,7 @@ export interface ActivitySign {
   id: number;
   activityId: number;
   userId: string;
-  status: number; // 0 未审核 / 1 通过 / -1 取消
+  status: number; // 0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go models_activity.go:31 ActivitySignedCancelled=0)
   recommendUserId: string | null;
   money: number; // 分
   /** 报名业务时间(契约 C1:`createAt`→`createTime`;端点 24/28 signeds 行) */
@@ -365,7 +365,7 @@ export interface Activity {
   id: number;
   institutionId: number;
   campusId: number;
-  type: 2 | 3 | 4; // 2 老带新 / 3 充值赠送 / 4 特殊费用
+  type: 2 | 3 | 4; // 2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20 ActivityTypeRecharge/Voucher/Recommend)
   name: string;
   status: 0 | 1; // 0 停用 / 1 启用(注意:status=0 仍允许报名,契约 §2.2)
   numberSign: number;
@@ -386,34 +386,38 @@ function mkSign(activityId: number, userId: string, status: number, recommendUse
 }
 const activities: Activity[] = [
   {
-    id: 1, institutionId: 1, campusId: 11, type: 2, name: '老带新推荐(数学)', status: 1, numberSign: 1,
-    total: 100, signed: 6, money: 20000, signable: true, startTime: rfc3339(BASE_MS - 40 * 86400_000), endTime: rfc3339(BASE_MS + 20 * 86400_000),
+    // type=4 老带新(Go ActivityTypeRecommend);status 覆盖 0取消/1报名/2参加/3报课
+    id: 1, institutionId: 1, campusId: 11, type: 4, name: '老带新推荐(数学)', status: 1, numberSign: 5,
+    total: 100, signed: 4, money: 20000, signable: true, startTime: rfc3339(BASE_MS - 40 * 86400_000), endTime: 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),
+      mkSign(1, ulid(203), 2, null, 20000),
+      mkSign(1, ulid(205), 3, null, 20000),
     ],
   },
   {
-    id: 2, institutionId: 1, campusId: 12, type: 3, name: '充值赠送 10%', status: 1, numberSign: 1,
-    total: 200, signed: 3, money: 10000, signable: true, startTime: rfc3339(BASE_MS - 20 * 86400_000), endTime: 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)],
+    // type=2 充值赠送(Go ActivityTypeRecharge);status 覆盖 1报名/4过期/0取消
+    id: 2, institutionId: 1, campusId: 12, type: 2, name: '充值赠送 10%', status: 1, numberSign: 3,
+    total: 200, signed: 2, money: 10000, signable: true, startTime: rfc3339(BASE_MS - 20 * 86400_000), endTime: rfc3339(BASE_MS + 40 * 86400_000),
+    signeds: [mkSign(2, ulid(203), 1, null, 10000), mkSign(2, ulid(205), 4, null, 15000), mkSign(2, ulid(204), 0, null, 8000)],
   },
   {
-    id: 3, institutionId: 1, campusId: 11, type: 4, name: '特殊费用减免', status: 0, numberSign: 1,
+    // type=3 代金券(Go ActivityTypeVoucher);活动 status=0 停用(仍允许报名,契约 §2.2)
+    id: 3, institutionId: 1, campusId: 11, type: 3, name: '代金券减免', status: 0, numberSign: 1,
     total: 50, signed: 1, money: 5000, signable: false, startTime: rfc3339(BASE_MS - 60 * 86400_000), endTime: 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,
+    // type=2 充值赠送;暂无报名
+    id: 4, institutionId: 1, campusId: 13, type: 2, name: '新学期充值有礼', status: 1, numberSign: 0,
     total: 150, signed: 0, money: 15000, signable: true, startTime: rfc3339(BASE_MS - 5 * 86400_000), endTime: rfc3339(BASE_MS + 35 * 86400_000),
     signeds: [],
   },
   {
-    id: 5, institutionId: 1, campusId: 12, type: 2, name: '老带新推荐(物理)', status: 1, numberSign: 1,
+    // type=4 老带新;2 条有效报名
+    id: 5, institutionId: 1, campusId: 12, type: 4, name: '老带新推荐(物理)', status: 1, numberSign: 2,
     total: 80, signed: 2, money: 30000, signable: true, startTime: rfc3339(BASE_MS - 15 * 86400_000), endTime: rfc3339(BASE_MS + 25 * 86400_000),
     signeds: [mkSign(5, ulid(203), 1, null, 30000), mkSign(5, ulid(205), 1, ulid(203), 30000)],
   },
@@ -519,6 +523,8 @@ export interface DivideNode {
   userId: string;
   realName: string;
   parentUserId: string | null;
+  /** 层级(根=1;Go MaxDivideDepth=100,业务约束 3 层) */
+  layer: number;
   /** 每次分成金额(元;A2:`quota` 是金额非 0~1 比率,源列 DECIMAL(12,2),与 total_income 同族;0.00 = 该链不分成) */
   quota: number;
   cumulativeIncome: number; // 分
@@ -526,18 +532,18 @@ export interface DivideNode {
 }
 const divideTree: DivideNode[] = [
   {
-    userId: ulid(303), realName: '超级管理员', parentUserId: null, quota: 500, cumulativeIncome: 0,
+    userId: ulid(303), realName: '超级管理员', parentUserId: null, layer: 1, quota: 500, cumulativeIncome: 0,
     children: [
       {
-        userId: ulid(101), realName: '张婷', parentUserId: ulid(303), quota: 150, cumulativeIncome: 1200000,
+        userId: ulid(101), realName: '张婷', parentUserId: ulid(303), layer: 2, quota: 150, cumulativeIncome: 1200000,
         children: [
-          { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), quota: 50, cumulativeIncome: 300000, children: [] },
-          { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认)
+          { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), layer: 3, quota: 50, cumulativeIncome: 300000, children: [] },
+          { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), layer: 3, quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认)
         ],
       },
       {
-        userId: ulid(102), realName: '李强', parentUserId: ulid(303), quota: 100, cumulativeIncome: 800000,
-        children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), quota: 30, cumulativeIncome: 200000, children: [] }],
+        userId: ulid(102), realName: '李强', parentUserId: ulid(303), layer: 2, quota: 100, cumulativeIncome: 800000,
+        children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), layer: 3, quota: 30, cumulativeIncome: 200000, children: [] }],
       },
     ],
   },

+ 92 - 51
src/api/mock/routes/activities.ts

@@ -1,11 +1,16 @@
 /**
  * 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)按校区活动赠送池返回。
+ * 契约对齐(academic-frontend-contract-v1.md v1.9 + 后端 2026-11-10 答复):
+ *   - type 语义:2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20);
+ *   - 报名行 status:0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go ActivitySignedCancelled=0);
+ *   - 报名入参:仅 userId/status?/recommendUserId?(**无 money**,报名金额=活动级 money 快照,
+ *     后端 SignInput 无 Money 字段,dump signed_add 5 参);
+ *   - 报名 status:<1 一律归 1(dump 同口径);
+ *   - 取消报名:status/activity_money/activity_money_used 三列同时归零(mock 以 status=0 + money=0 表达);
+ *   - 3106 老带新每人限一次(**type=4** 判据)/ 3108 老带新已消费(**type=4** 且 money>0);
+ *   - 端点 31 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003;
+ *   - 端点 32 赠送不足 → 3109(Go 增强;金额≤0 仍是 no-op used:false)。
  *
  * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
  */
@@ -14,6 +19,7 @@ import { toCents, fmt } from '@/api/mock/money';
 import { rfc3339 } from '@/api/mock/time';
 import { inScope, findUser, findActivity, queryActivities } from '@/api/mock/data';
 import type { Activity, ActivitySign } from '@/api/mock/data';
+import { requireRole } from '@/api/mock/routes/auth';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
 import type { R } from '@/types/api';
 
@@ -47,7 +53,7 @@ function activityRow(a: Activity): Record<string, unknown> {
     status: a.status,
     numberSign: a.numberSign,
     total: a.total,
-    signed: a.signeds.filter((s) => s.status === 1).length,
+    signed: a.signeds.filter((s) => s.status !== 0).length,
     money: fmt(a.money),
     signable: a.signable,
     startTime: a.startTime,
@@ -63,9 +69,14 @@ export const activityRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
+      const type = ctx.query.type !== undefined ? Number(ctx.query.type) : undefined;
+      const status = ctx.query.status !== undefined ? Number(ctx.query.status) : undefined;
+      // 越界枚举 → 2002(type 白名单 2/3/4;1 试听为旧遗留不接受)
+      if (type !== undefined && ![2, 3, 4].includes(type)) return fail(2002, 'type 越界(2/3/4)');
+      if (status !== undefined && ![0, 1, 2].includes(status)) return fail(2002, 'status 越界(0/1/2)');
       const rows = queryActivities({
-        type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
-        status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined,
+        type,
+        status,
         keyword: ctx.query.keyword,
         institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
         campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
@@ -80,11 +91,13 @@ export const activityRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      const name = ctx.body?.name as string | undefined;
+      // 请求字段是 title(契约/后端 dto_activity.go json:"title");name 仅响应侧
+      const title = (ctx.body?.title ?? 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)');
+      if (!title) return fail(2001, 'title 缺失');
+      if (![2, 3, 4].includes(type)) return fail(2002, 'type 非法(2 充值赠送 / 3 代金券 / 4 老带新)');
       const newId = 100 + Math.floor(Math.random() * 900);
-      // v1.6 B5:t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo(活动是内部配置实体,无需对外单号)
+      // t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo
       return ok({ id: newId });
     },
   },
@@ -97,11 +110,10 @@ export const activityRoutes: MockRoute[] = [
       if (denied) return denied;
       const a = findActivity(Number(ctx.params.id));
       if (!a) return fail(3001, '活动不存在');
-      // v1.6 C2/E6:详情同 22 行形状(startTime/endTime + 活动级 money),内嵌 signeds(上限 200,超出走端点 28)
       return ok({ ...activityRow(a), signeds: a.signeds.slice(0, 200).map(signRow) });
     },
   },
-  /* 25. PUT /api/admin/activities/:id —— 更新(numberSign 不可写) */
+  /* 25. PUT /api/admin/activities/:id —— 更新(整体替换:未传 content/memo/thumbnail 置空;numberSign 禁写) */
   {
     method: 'PUT',
     path: '/api/admin/activities/:id',
@@ -110,13 +122,19 @@ export const activityRoutes: MockRoute[] = [
       if (denied) return denied;
       const a = findActivity(Number(ctx.params.id));
       if (!a) return fail(3001, '活动不存在');
-      // v1.6 E5:numberSign 不可写属"传了不可写字段"(单一参数非法)⇒ 2002(3140 只收"参数互相矛盾")
-      if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改');
-      a.name = (ctx.body?.name as string) ?? a.name;
+      // 整体替换语义:未传字段置空(mock 简化:仅记"本次提交带了哪些可选字段",未带即清空)
+      if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改(单一参数非法,非 3140)');
+      const title = (ctx.body?.title ?? ctx.body?.name) as string | undefined;
+      if (!title) return fail(2001, 'title 缺失(整体替换后不能没有标题)');
+      a.name = title;
+      // 整体替换:未传 content/memo/thumbnail 被置空 —— mock 以"记录当前有效值"表达,前端编辑页须回填全字段
+      (a as Activity & { content?: string; memo?: string; thumbnail?: string }).content = (ctx.body?.content as string) ?? '';
+      (a as Activity & { content?: string; memo?: string; thumbnail?: string }).memo = (ctx.body?.memo as string) ?? '';
+      (a as Activity & { content?: string; memo?: string; thumbnail?: string }).thumbnail = (ctx.body?.thumbnail as string) ?? '';
       return ok({ id: a.id });
     },
   },
-  /* 26. POST /api/admin/activities/:id/sign —— 报名(3105/3106 分支;status=0 仍允许) */
+  /* 26. POST /api/admin/activities/:id/sign —— 报名(无 money 入参;status<1 归 1;3105/3106 分支) */
   {
     method: 'POST',
     path: '/api/admin/activities/:id/sign',
@@ -127,24 +145,26 @@ export const activityRoutes: MockRoute[] = [
       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, '老带新每人限参加一次');
+      // status < 1 一律归 1(dump 同口径)
+      const status = Math.max(1, Number(ctx.body?.status ?? 1));
+      // 报名金额 = 活动级 money 快照(后端 SignInput 无 Money 字段,不读 body.money)
+      // 3105:已报名该活动(存在 status!==0 的本人报名)
+      if (a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3105, '已报名该活动');
+      // 3106:老带新(type=4)每人限参加一次
+      if (a.type === 4 && a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3106, '老带新每人限参加一次');
       a.signeds.push({
         id: Date.now(),
         activityId: a.id,
         userId,
-        status: Number(ctx.body?.status ?? 0),
+        status,
         recommendUserId: (ctx.body?.recommendUserId as string) ?? null,
-        money: toCents(String(ctx.body?.money ?? '0.00')),
+        money: a.money, // 活动级快照(非入参)
         createTime: rfc3339(Date.now()),
       });
       return ok({ signedId: a.signeds[a.signeds.length - 1].id });
     },
   },
-  /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(3107/3108;归零金额) */
+  /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(status=0 + money 归零;3107/3108 分支) */
   {
     method: 'POST',
     path: '/api/admin/activities/:id/cancel-sign',
@@ -155,18 +175,19 @@ export const activityRoutes: MockRoute[] = [
       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);
+      const s = a.signeds.find((x) => x.userId === userId && x.status !== 0);
       if (!s) return fail(3001, '未找到有效报名');
-      // v1.6 E7:3107 判据按"报名/活动状态"判(名额报满 = 视为已结算/审核通过),不再用"报名人数过半"近似
-      if (a.signeds.filter((x) => x.status === 1).length >= a.total) return fail(3107, '活动已审核通过,不可取消');
-      // 3108:老带新已产生消费不可取消(type=2 且 money 已核销 = money>0)
-      if (a.type === 2 && s.money > 0) return fail(3108, '老带新已产生消费,不可取消');
-      s.status = -1;
-      s.money = 0; // activityMoney/activityMoneyUsed 归零(契约 §2.2:前端刷新整行)
+      // 3107:活动已审核通过不可取消(type 2/3 充值赠送/代金券 视为已结算)
+      if ((a.type === 2 || a.type === 3) && s.status >= 2) return fail(3107, '活动已审核通过,不可取消');
+      // 3108:老带新(type=4)已产生消费(money 已核销 > 0)
+      if (a.type === 4 && s.money > 0 && s.status >= 2) return fail(3108, '老带新已产生消费,不可取消');
+      // 三列同时归零:status→0 + activity_money/activity_money_used→0
+      s.status = 0;
+      s.money = 0;
       return ok({ cancelled: true });
     },
   },
-  /* 28. GET /api/admin/activities/:id/signeds —— 报名列表 */
+  /* 28. GET /api/admin/activities/:id/signeds —— 报名列表(status 可筛 0~4) */
   {
     method: 'GET',
     path: '/api/admin/activities/:id/signeds',
@@ -176,11 +197,15 @@ export const activityRoutes: MockRoute[] = [
       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));
+      if (ctx.query.status !== undefined) {
+        const st = Number(ctx.query.status);
+        if (![0, 1, 2, 3, 4].includes(st)) return fail(2002, 'status 越界(0~4)');
+        rows = rows.filter((s) => s.status === st);
+      }
       return page(rows.map(signRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
   },
-  /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算 */
+  /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算(settled:false = 正常 LEAVE,非错误) */
   {
     method: 'POST',
     path: '/api/admin/activities/:id/recommend-settle',
@@ -193,7 +218,7 @@ export const activityRoutes: MockRoute[] = [
       return ok({ settled: true, activityId: a.id });
     },
   },
-  /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动 */
+  /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动(反向扣款,无余额上限校验) */
   {
     method: 'POST',
     path: '/api/admin/activities/:id/cancel-recharge',
@@ -206,7 +231,7 @@ export const activityRoutes: MockRoute[] = [
       return ok({ cancelled: true });
     },
   },
-  /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送 */
+  /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送(平台视角 1003 校验) */
   {
     method: 'GET',
     path: '/api/admin/activities/discounts/usable',
@@ -215,11 +240,16 @@ export const activityRoutes: MockRoute[] = [
       if (denied) return denied;
       const userId = ctx.query.userId;
       if (!userId) return fail(2001, 'userId 缺失');
+      const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 0;
+      // 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003
+      if (institutionId <= 0) {
+        const deniedRole = requireRole(ctx, ['super_admin', 'finance']);
+        if (deniedRole) return deniedRole;
+      }
       // mock:按用户主校区返回可用赠送池
       const u = findUser(userId);
       const campus = u?.campusId ?? 11;
-      // v1.6 B5:赠送池表无 memo 列 ⇒ items 不带 memo(不得用活动标题顶替)。
-      // usable = Σ items(同一快照);按 activityId 升序。
+      // 赠送池表无 memo 列 ⇒ items 不带 memo;usable = Σ items(同一快照);按 activityId 升序
       const a = 20000;
       const b = 12000 + campus * 1000;
       return ok({
@@ -233,27 +263,35 @@ export const activityRoutes: MockRoute[] = [
       });
     },
   },
-  /* 32. POST /api/admin/activities/discounts/use —— 使用赠送 */
+  /* 32. POST /api/admin/activities/discounts/use —— 使用赠送(≤0 no-op;金额>可用池 → 3109) */
   {
     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, '参数缺失');
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
+      // 金额 ≤0 → no-op(used:false,不报错);请求金额须为字符串两位小数(数字 → 2002)
+      const raw = ctx.body?.amount;
+      if (raw !== undefined) {
+        if (typeof raw !== 'string') return fail(2002, 'amount 须为字符串两位小数');
+        const c = toCents(raw);
+        if (c <= 0) return ok({ used: false });
+        // mock 可用池:活动 2 = 200.00 / 活动 4 = 120.00+(与端点 31 同池)
+        const pool = Number(ctx.body.activityId) === 2 ? 20000 : 13000;
+        if (c > pool) return fail(3109, '可用赠送金额不足');
       }
       return ok({ used: true });
     },
   },
-  /* 33. POST /api/admin/activities/discounts/refund —— 退回赠送 */
+  /* 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, '参数缺失');
+      if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
       return ok({ refunded: true });
     },
   },
@@ -264,20 +302,23 @@ export const activityRoutes: MockRoute[] = [
     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, '参数缺失');
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
       return ok({ refunded: true });
     },
   },
-  /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送 */
+  /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送(报名行须 status=2) */
   {
     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, '参数缺失');
-      }
+      if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
+      const userId = ctx.body.userId as string;
+      const activityId = Number(ctx.body.activityId);
+      const act = findActivity(activityId);
+      const s = act?.signeds.find((x) => x.userId === userId);
+      if (!s || s.status !== 2) return fail(3001, '该活动报名行未参加活动(需 status=2)');
       return ok({ used: true });
     },
   },
@@ -288,11 +329,11 @@ export const activityRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      if (ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
+      if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
       return ok({ refunded: true });
     },
   },
-  /* 37. POST /api/admin/activities/:id/visits —— 活动到访 */
+  /* 37. POST /api/admin/activities/:id/visits —— 回访登记(activitySignedId + memo 必填) */
   {
     method: 'POST',
     path: '/api/admin/activities/:id/visits',

+ 44 - 56
src/api/mock/routes/divide-into.ts

@@ -1,13 +1,20 @@
 /**
  * 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 小数比);
+ * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结 + v1.10 回改):
+ *   - 端点 38 树查询:`rootUserId?` → Nested Set 树(含 quota / 累计收入 / layer;金额恒字符串两位小数);
+ *     出参 `{ tree, nodeCount, rootCount }`(Go divide_tree_service.go DivideTreeResult);
  *   - 端点 39 添加下线:`parentUserId,subUserId` → {ancestorIds:[]};**新节点默认 quota=0.00**(契约 §2.3 注释:
  *     建完下线须显式调端点 40 设置额度,否则该链不产生分成且不报错);
  *   - 端点 40 设额度:`quota` → {affectedUserIds:[]}(含该节点及其全部下线,额度变化影响子链分成);
- *   - 端点 41/42/43 分账流水:建(findUserId,studentId,orderNo,remark?)/ 删(studentId,orderNo)/ 查(分页 + 时间范围);
- *   - 分支:3111(分账层级超限 / **子额度 > 父额度**,v1.6 A2 判据更正)/ 2001(缺参)/ 3001(节点/用户不存在)/ 1004(越界)。
+ *   - 端点 41 记分账流水:`findUserId,studentId,orderNo,remark?` → **{ logged: true }**(B-1 定案:Go 不返回 logId);
+ *   - 端点 42 删分账流水:`studentId,orderNo` → { deleted: true };
+ *   - 端点 43 查分账流水:分页 + 时间范围(**右开**:`from <= createTime < to`,B-3 定案);
+ *   - 分支:3111(分账层级超限 / 子额度 > 父额度)/ 2001(缺参)/ 2002(格式错)/ 3001(节点/用户不存在)。
+ *
+ * ⚠ B-4 定案(后端答复 2026-11-10):分账域**无机构维度**(t_user_divide_into / t_user_divide_into_log
+ *   无 institution_id/campus_id 列),Go 源码不返回 1004。mock 移除 checkScope,
+ *   越权防线仅 1001(登录态,由 adapter 统一拦截)+ 1003(角色白名单,由 requireRole 处理)。
  *
  * 数据源:分账树来自 data.ts(getDivideTree / findDivideNode,可变引用,39 直接挂新节点);
  *   分账流水为模块级单例数组(dev 会话内持久,刷新即重置,与 mock 定位一致)。
@@ -17,12 +24,11 @@
 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 { 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';
+import type { MockRoute } from '@/api/mock/adapter';
 
-/** 分账层级上限(契约 §6 数据策略:超管 → 校区主任 → 老师,3 层;第 4 层即超限 → 3111)。 */
+/** 分账层级上限(业务规则:超管 → 校区主任 → 老师,3 层;第 4 层即超限 → 3111。B-2 待业务拍板,暂保留 3)。 */
 const MAX_DIVIDE_DEPTH = 3;
 
 /** 分账流水(模块级单例;dev 会话内可变)。 */
@@ -42,39 +48,22 @@ const logs: DivideLog[] = [
   { id: ++logSeq, findUserId: '01J2M' + '102'.padStart(21, '0'), studentId: '01J2M' + '203'.padStart(21, '0'), orderNo: 'ORD20260901002', money: 20000, remark: '物理课时分成', createTime: 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。出参:对象。 */
+/** 树节点 → 输出行(quota 两位小数字符串、累计收入金额字符串、layer 原样;children 递归)。入参:node。出参:对象。 */
 function nodeRow(n: DivideNode): Record<string, unknown> {
   return {
     userId: n.userId,
     realName: n.realName,
     parentUserId: n.parentUserId,
+    layer: n.layer,
     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;
+/** 统计树节点总数(含根)。入参:nodes。出参:number。 */
+function countNodes(nodes: DivideNode[]): number {
+  return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children), 0);
 }
 
 /** 收集节点自身 + 全部下线的 userId(额度变化影响子链分成)。入参:node。出参:string[]。 */
@@ -83,30 +72,30 @@ function subtreeUserIds(n: DivideNode): string[] {
 }
 
 export const divideIntoRoutes: MockRoute[] = [
-  /* 38. GET /api/admin/divide-into/tree —— Nested Set 树(rootUserId? 定位根;缺省返回全部根) */
+  /* 38. GET /api/admin/divide-into/tree —— Nested Set 树(rootUserId? 定位根;缺省返回全部根)
+   * 出参:{ tree, nodeCount, rootCount }(Go DivideTreeResult;B-4:无 1004) */
   {
     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)] });
+        const tree = [nodeRow(root)];
+        return ok({ tree, nodeCount: countNodes([root]), rootCount: 1 });
       }
-      return ok({ tree: roots.map(nodeRow) });
+      const tree = roots.map(nodeRow);
+      return ok({ tree, nodeCount: countNodes(roots), rootCount: roots.length });
     },
   },
-  /* 39. POST /api/admin/divide-into/members —— 添加下线(新节点 quota=0.00;层级超限 3111) */
+  /* 39. POST /api/admin/divide-into/members —— 添加下线(新节点 quota=0.00;层级超限 3111)
+   * B-4:无 1004(分账域无机构维度) */
   {
     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 缺失');
@@ -115,19 +104,18 @@ export const divideIntoRoutes: MockRoute[] = [
       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: [] });
+      if (parent.layer >= MAX_DIVIDE_DEPTH) return fail(3111, '分账层级超限(最多 3 层)');
+      // 挂新节点:quota 默认 0(契约 §2.3:不设置额度则该链不分成);layer = 父+1
+      parent.children.push({ userId: subUserId, realName: findUser(subUserId)!.realName, parentUserId, layer: parent.layer + 1, quota: 0, cumulativeIncome: 0, children: [] });
       return ok({ ancestorIds: [] });
     },
   },
-  /* 40. PUT /api/admin/divide-into/members/:userId/quota —— 设额度(>1 超限 3111;影响子链) */
+  /* 40. PUT /api/admin/divide-into/members/:userId/quota —— 设额度(子额度>父额度 3111;影响子链)
+   * B-4:无 1004 */
   {
     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, '分账节点不存在');
       // v1.6 A2:quota 是**金额**(每次分成金额,元,两位小数),不是 0~1 比率;0.00 是合法值(该链不分成)
@@ -141,13 +129,13 @@ export const divideIntoRoutes: MockRoute[] = [
       return ok({ affectedUserIds: subtreeUserIds(node) });
     },
   },
-  /* 41. POST /api/admin/divide-into/logs —— 记分账流水 {logId} */
+  /* 41. POST /api/admin/divide-into/logs —— 记分账流水
+   * B-1 定案:Go 返回 { logged: true }(不返回 logId;dump divide_into_log_add 不回读 id)
+   * B-4:无 1004 */
   {
     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;
@@ -164,16 +152,15 @@ export const divideIntoRoutes: MockRoute[] = [
         createTime: rfc3339(Date.now()),
       };
       logs.unshift(log);
-      return ok({ logId: log.id });
+      return ok({ logged: true });
     },
   },
-  /* 42. DELETE /api/admin/divide-into/logs —— 删分账流水(studentId,orderNo) */
+  /* 42. DELETE /api/admin/divide-into/logs —— 删分账流水(studentId,orderNo)
+   * B-4:无 1004 */
   {
     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 缺失');
@@ -183,22 +170,23 @@ export const divideIntoRoutes: MockRoute[] = [
       return ok({ deleted: true });
     },
   },
-  /* 43. GET /api/admin/divide-into/logs —— 分账流水列表(userId?/studentId?/时间范围,分页) */
+  /* 43. GET /api/admin/divide-into/logs —— 分账流水列表(userId?/studentId?/时间范围,分页)
+   * B-3 定案:右开区间 from <= createTime < to(to 传次日 00:00 才含当日)
+   * B-4:无 1004 */
   {
     method: 'GET',
     path: '/api/admin/divide-into/logs',
     handler: (ctx) => {
-      const denied = checkScope(ctx);
-      if (denied) return denied;
+      // 右开:from 取当日 00:00(含),to 取当日 00:00(不含)→ from <= t < to
       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 to = ctx.query.to ? Date.parse(`${ctx.query.to}T00:00:00+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.createTime);
           if (from !== null && t < from) return false;
-          if (to !== null && t > to) return false;
+          if (to !== null && t >= to) return false;
           return true;
         })
         .sort((a, b) => (a.createTime < b.createTime ? 1 : a.createTime > b.createTime ? -1 : b.id - a.id));

+ 14 - 17
src/views/academic/activity/index.vue

@@ -23,7 +23,7 @@ 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, moneyPlain } from '@/utils/format';
+import { formatDateTime } from '@/utils/format';
 
 const scope = useScope();
 
@@ -56,8 +56,10 @@ const { rows: activities, total, page, pageSize, loading, error, fetchList } = u
   fetcher: (p, ps) =>
     listActivities({ ...scope.toQuery(), keyword: filters.keyword, type: filters.type === 'all' ? undefined : Number(filters.type), page: p, pageSize: ps }),
 });
-const typeText: Record<number, string> = { 2: '老带新', 3: '充值赠送', 4: '特殊费用' };
-const statusText: Record<number, string> = { 0: '停用', 1: '启用' };
+/** 活动类型(Go:2 充值赠送 / 3 代金券 / 4 老带新) */
+const typeText: Record<number, string> = { 2: '充值赠送', 3: '代金券', 4: '老带新' };
+/** 活动状态(0 停用 / 1 开启 / 2 已结束) */
+const statusText: Record<number, string> = { 0: '停用', 1: '开启', 2: '已结束' };
 const columns: ListColumn<ActivityDTO>[] = [
   { prop: 'id', label: '活动 id', minWidth: 90 },
   { prop: 'name', label: '活动名称', minWidth: 180 },
@@ -99,15 +101,13 @@ async function loadSigneds(): Promise<void> {
 // 选中活动变化 → 重新拉报名明细
 watch(selectedActivityId, loadSigneds);
 
-/** 新增报名对话框(端点 26);money 本地用 number(el-input-number),提交时 moneyPlain 转字符串 */
+/** 新增报名对话框(端点 26);入参仅 userId/recommendUserId(**无 money**,报名金额=活动级 money 快照,后端取) */
 const signVisible = ref(false);
 const signBusy = ref(false);
-const signForm = reactive({ userId: '', money: null as number | null, recommendUserId: '' });
+const signForm = reactive({ userId: '', recommendUserId: '' });
 function openSign(): void {
   if (!selectedActivity.value) return;
   signForm.userId = studentOpts.value[0]?.userId ?? '';
-  // 默认金额取活动金额(活动级 money 字符串两位小数 → number);可改
-  signForm.money = Number(selectedActivity.value.money) || null;
   signForm.recommendUserId = '';
   signVisible.value = true;
 }
@@ -119,10 +119,9 @@ async function doSign(): Promise<void> {
   }
   signBusy.value = true;
   try {
-    const body: { userId: string; money?: string; recommendUserId?: string } = { userId: signForm.userId };
-    if (signForm.money && signForm.money > 0) body.money = moneyPlain(signForm.money);
-    // 老带新(type=2)才带推荐人(可空)
-    if (selectedActivity.value.type === 2 && signForm.recommendUserId) body.recommendUserId = signForm.recommendUserId;
+    const body: { userId: string; recommendUserId?: string } = { userId: signForm.userId };
+    // 老带新(type=4)才带推荐人(可空)
+    if (selectedActivity.value.type === 4 && signForm.recommendUserId) body.recommendUserId = signForm.recommendUserId;
     const r = await signActivity(selectedActivity.value.id, body);
     if (r.code === 200) {
       ElMessage_('success', '报名成功');
@@ -157,7 +156,8 @@ async function doCancel(s: ActivitySignDTO): Promise<void> {
   }
 }
 
-const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消' };
+/** 报名行状态(0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期) */
+const signStatusText: Record<number, string> = { 0: '已取消', 1: '报名', 2: '参加', 3: '报课', 4: '过期' };
 </script>
 
 <template>
@@ -177,7 +177,7 @@ const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消'
         <el-input v-model="filters.keyword" placeholder="搜索活动名称" clearable class="toolbar-input" />
         <el-segmented
           v-model="filters.type"
-          :options="[{ label: '全部', value: 'all' }, { label: '老带新', value: '2' }, { label: '充值赠送', value: '3' }, { label: '特殊费用', value: '4' }]"
+          :options="[{ label: '全部', value: 'all' }, { label: '充值赠送', value: '2' }, { label: '代金券', value: '3' }, { label: '老带新', value: '4' }]"
         />
       </template>
     </ListPage>
@@ -229,10 +229,7 @@ const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消'
             <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
           </el-select>
         </el-form-item>
-        <el-form-item label="报名金额">
-          <el-input-number v-model="signForm.money" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
-        </el-form-item>
-        <el-form-item v-if="selectedActivity?.type === 2" label="推荐人">
+        <el-form-item v-if="selectedActivity?.type === 4" label="推荐人">
           <el-select v-model="signForm.recommendUserId" filterable clearable placeholder="老带新推荐人(可空)" style="width: 100%">
             <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
           </el-select>