Przeglądaj źródła

feat(activity,divide): 批次 B 活动页报名/取消 + 分账页写操作(端点 26/27/28 + 39/40/41/42/43)

Q 21 godzin temu
rodzic
commit
a067a694f2

+ 50 - 1
src/api/academic/activity.ts

@@ -8,7 +8,7 @@
  *   - 金额字符串两位小数;时间 RFC3339;分页 PageData 五字段;status 为数字码(中文前端映射)。
  */
 import { http } from '@/api/http';
-import type { PageR } from '@/types/api';
+import type { PageR, R } from '@/types/api';
 
 /** 活动 DTO(端点 22 列表行) */
 export interface ActivityDTO {
@@ -62,3 +62,52 @@ export type ActivityQuery = {
 export function listActivities(params: ActivityQuery): Promise<PageR<ActivityDTO>> {
   return http.get<PageR<ActivityDTO>>('/api/admin/activities', { params }).then((r) => r.data);
 }
+
+/** 报名行 DTO(端点 24 详情 signeds[] / 端点 28 报名列表行;两处同结构) */
+export interface ActivitySignDTO {
+  /** 报名行 id */
+  id: number;
+  /** 活动 id */
+  activityId: number;
+  /** 报名用户 id(canonical ULID) */
+  userId: string;
+  /** 状态数字码(1 有效;-1 已取消,前端映射) */
+  status: number;
+  /** 推荐人(老带新)用户 id,可空 */
+  recommendUserId: string | null;
+  /** 报名级金额(字符串两位小数;注意与活动顶层 money 不同层级) */
+  money: string;
+  /** 报名时间(RFC3339) */
+  createTime: string;
+}
+
+/** 报名(端点 26,POST /api/admin/activities/:id/sign)。入参:activityId + body。出参 code(3105 已报名 / 3106 老带新限一次)。 */
+export function signActivity(
+  activityId: number,
+  body: { userId: string; money?: string; recommendUserId?: string },
+): Promise<R<{ signedId: number }>> {
+  return http.post<R<{ signedId: number }>>(`/api/admin/activities/${activityId}/sign`, body).then((r) => r.data);
+}
+
+/** 取消报名(端点 27,POST /api/admin/activities/:id/cancel-sign)。出参 code(3107 已审核 / 3108 老带新已消费)。 */
+export function cancelSign(
+  activityId: number,
+  body: { userId: string },
+): Promise<R<{ cancelled: boolean }>> {
+  return http.post<R<{ cancelled: boolean }>>(`/api/admin/activities/${activityId}/cancel-sign`, body).then((r) => r.data);
+}
+
+/** 报名列表查询参数(端点 28;status 可过滤,缺省取全部)。 */
+export type SignQuery = {
+  /** 状态筛选(1 有效;缺省全部) */
+  status?: number;
+  /** 页码 */
+  page?: number;
+  /** 每页条数 */
+  pageSize?: number;
+};
+
+/** 报名明细列表(端点 28,GET /api/admin/activities/:id/signeds)。 */
+export function listSigneds(activityId: number, params: SignQuery): Promise<PageR<ActivitySignDTO>> {
+  return http.get<PageR<ActivitySignDTO>>(`/api/admin/activities/${activityId}/signeds`, { params }).then((r) => r.data);
+}

+ 78 - 1
src/api/academic/divide-into.ts

@@ -8,7 +8,7 @@
  *   - quota 为金额字符串(每次分成金额,v1.6 已从比率改金额);累计收入字符串两位小数;时间 RFC3339。
  */
 import { http } from '@/api/http';
-import type { R } from '@/types/api';
+import type { PageR, R } from '@/types/api';
 
 /** 分账节点 DTO(递归树;与 mock nodeRow 逐字段对齐) */
 export interface DivideNodeDTO {
@@ -40,3 +40,80 @@ export type DivideTreeQuery = {
 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);
 }
+
+/** 添加下线(端点 39,POST /api/admin/divide-into/members)。新节点默认 quota=0.00。出参 code(3111 层级超限)。 */
+export function addDivideMember(body: {
+  /** 上级用户 id(须在分账树中) */
+  parentUserId: string;
+  /** 下线用户 id(canonical ULID,须存在且不在树中) */
+  subUserId: string;
+}): Promise<R<{ ancestorIds: string[] }>> {
+  return http.post<R<{ ancestorIds: string[] }>>('/api/admin/divide-into/members', body).then((r) => r.data);
+}
+
+/** 设置分成额度(端点 40,PUT /api/admin/divide-into/members/:userId/quota)。quota 为金额字符串。出参 code(3111 子额度>父额度)。 */
+export function setDivideQuota(userId: string, body: { quota: string }): Promise<R<{ affectedUserIds: string[] }>> {
+  return http.put<R<{ affectedUserIds: string[] }>>(`/api/admin/divide-into/members/${userId}/quota`, body).then((r) => r.data);
+}
+
+/** 分账流水行 DTO(端点 43 列表行) */
+export interface DivideLogDTO {
+  /** 流水 id */
+  id: number;
+  /** 分账人(canonical ULID) */
+  findUserId: string;
+  /** 学生 id(canonical ULID) */
+  studentId: string;
+  /** 订单号 */
+  orderNo: string;
+  /** 金额(字符串两位小数) */
+  money: string;
+  /** 备注 */
+  remark: string;
+  /** 时间(RFC3339) */
+  createTime: string;
+}
+
+/** 记分账流水(端点 41,POST /api/admin/divide-into/logs)。出参 code(3001 分账人不在树/学生不存在)。 */
+export function createDivideLog(body: {
+  /** 分账人(须在分账树中) */
+  findUserId: string;
+  /** 学生 id(须存在) */
+  studentId: string;
+  /** 订单号 */
+  orderNo: string;
+  /** 备注(可空) */
+  remark?: string;
+}): Promise<R<{ logId: number }>> {
+  return http.post<R<{ logId: number }>>('/api/admin/divide-into/logs', body).then((r) => r.data);
+}
+
+/** 删分账流水(端点 42,DELETE /api/admin/divide-into/logs)。按 studentId+orderNo 定位。出参 code(3001 不存在)。 */
+export function deleteDivideLog(params: { studentId: string; orderNo: string }): Promise<R<{ deleted: boolean }>> {
+  return http.delete<R<{ deleted: boolean }>>('/api/admin/divide-into/logs', { params }).then((r) => r.data);
+}
+
+/** 分账流水查询参数(端点 43;作用域由 toQuery 注入;userId 实为 findUserId 筛选)。 */
+export type DivideLogQuery = {
+  /** 机构 id */
+  institutionId: number;
+  /** 校区 id(可空) */
+  campusId?: number;
+  /** 分账人 id 筛选(可空) */
+  userId?: string;
+  /** 学生 id 筛选(可空) */
+  studentId?: string;
+  /** 起始日期(可空,闭区间) */
+  from?: string;
+  /** 结束日期(可空) */
+  to?: string;
+  /** 页码 */
+  page?: number;
+  /** 每页条数 */
+  pageSize?: number;
+};
+
+/** 分账流水列表(端点 43,GET /api/admin/divide-into/logs,分页)。 */
+export function listDivideLogs(params: DivideLogQuery): Promise<PageR<DivideLogDTO>> {
+  return http.get<PageR<DivideLogDTO>>('/api/admin/divide-into/logs', { params }).then((r) => r.data);
+}

+ 216 - 15
src/views/academic/activity/index.vue

@@ -1,9 +1,24 @@
 <script setup lang="ts">
 /**
- * 活动页(views/academic/activity/index.vue)—— 活动列表(菜单「活动」,端点 22)。
- * useListPage + ListPage;金额字符串两位小数;时间 RFC3339;status 数字码前端映射。
+ * 活动页(views/academic/activity/index.vue)—— 活动列表(端点 22)+ 报名管理(端点 26/27/28)。
+ *
+ * 结构(自上而下):
+ *   ① 活动列表(ListPage,端点 22;类型/状态筛选,浏览用);
+ *   ② 报名管理区块:选活动(el-select)→ 加载报名明细(端点 28)→ 行内"取消报名"(端点 27)
+ *      + "新增报名"对话框(选学生/金额/推荐人 → 端点 26)。
+ *
+ * 纪律(契约 v1.9 §2.2):
+ *   - 报名交互:活动 status=0(停用)仍允许报名(dump 无可用性校验)→ 不自行禁用按钮,以接口返回为准;
+ *   - 报名分支 3105(已报名)/ 3106(老带新限一次):读 code 直显 message,不当成功;
+ *   - 取消分支 3107(已审核)/ 3108(老带新已消费):同上;取消会把 activityMoney/activityMoneyUsed
+ *     归零 → 成功后**刷新整行**(重新拉报名明细 + 活动列表 signed 计数);
+ *   - 两个 money 不同层级:活动顶层 money(活动金额)/ 报名行 signeds[].money(报名级);
+ *   - 金额字符串两位小数;时间 RFC3339。
  */
-import { listActivities, type ActivityDTO } from '@/api/academic/activity';
+import { listActivities, signActivity, cancelSign, listSigneds } from '@/api/academic/activity';
+import type { ActivityDTO, ActivitySignDTO } from '@/api/academic/activity';
+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';
@@ -11,24 +26,46 @@ 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 }),
+/** 命令式 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 studentOpts = ref<MemberDTO[]>([]);
+const studentName = (id: string | null): string => (id ? studentOpts.value.find((m) => m.userId === id)?.realName ?? id : '—');
+onMounted(async () => {
+  const r = await listMembers({ institutionId: scope.current.institutionId, personType: 'student', pageSize: 200 });
+  if (r.code === 200) studentOpts.value = r.data?.list ?? [];
 });
 
-const typeText: Record<number, string> = { 2: '充值赠送', 3: '老带新', 4: '特殊费用' };
-const statusText: Record<number, string> = { 0: '未开始', 1: '报名中', 2: '进行中', 3: '已结束' };
+/* ================= ① 活动列表(端点 22) ================= */
+const filters = reactive<{ keyword: string; type: number | undefined }>({ keyword: '', type: undefined });
+const { rows: activities, 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: '启用' };
 const columns: ListColumn<ActivityDTO>[] = [
   { prop: 'id', label: '活动 id', minWidth: 90 },
-  { prop: 'name', label: '活动名称', minWidth: 160 },
+  { prop: 'name', label: '活动名称', minWidth: 180 },
   { 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: 'startTime', label: '开始', minWidth: 140, formatter: (r) => formatDateTime(r.startTime) },
-  { prop: 'endTime', label: '结束', minWidth: 140, formatter: (r) => formatDateTime(r.endTime) },
+  { prop: 'status', label: '状态', minWidth: 80, formatter: (r) => statusText[r.status] ?? String(r.status) },
+  { prop: 'signed', label: '报名', minWidth: 90, formatter: (r) => `${r.signed}/${r.total}` },
+  { prop: 'money', label: '活动金额', minWidth: 100 },
+  { prop: 'startTime', label: '开始', minWidth: 150, formatter: (r) => formatDateTime(r.startTime) },
+  { prop: 'endTime', label: '结束', minWidth: 150, formatter: (r) => formatDateTime(r.endTime) },
 ];
-
 function onSearch(): void {
   page.value = 1;
   void fetchList();
@@ -36,14 +73,98 @@ function onSearch(): void {
 onMounted(() => {
   void fetchList();
 });
+
+/* ================= ② 报名管理(端点 26/27/28) ================= */
+const selectedActivityId = ref<number | undefined>(undefined);
+const selectedActivity = computed(() => activities.value.find((a) => a.id === selectedActivityId.value) ?? null);
+
+/** 报名明细(端点 28;上限 200,超出走分页,本页取全量) */
+const signeds = ref<ActivitySignDTO[]>([]);
+const signedLoading = ref(false);
+async function loadSigneds(): Promise<void> {
+  if (!selectedActivityId.value) {
+    signeds.value = [];
+    return;
+  }
+  signedLoading.value = true;
+  try {
+    const r = await listSigneds(selectedActivityId.value, { pageSize: 200 });
+    if (r.code === 200) signeds.value = r.data?.list ?? [];
+  } finally {
+    signedLoading.value = false;
+  }
+}
+// 选中活动变化 → 重新拉报名明细
+watch(selectedActivityId, loadSigneds);
+
+/** 新增报名对话框(端点 26) */
+const signVisible = ref(false);
+const signBusy = ref(false);
+const signForm = reactive({ userId: '', money: '', recommendUserId: '' });
+function openSign(): void {
+  if (!selectedActivity.value) return;
+  signForm.userId = studentOpts.value[0]?.userId ?? '';
+  // 默认金额取活动金额(活动级 money);可改
+  signForm.money = selectedActivity.value.money;
+  signForm.recommendUserId = '';
+  signVisible.value = true;
+}
+async function doSign(): Promise<void> {
+  if (!selectedActivity.value) return;
+  if (!signForm.userId) {
+    ElMessage_('warning', '请选择报名学生');
+    return;
+  }
+  signBusy.value = true;
+  try {
+    const body: { userId: string; money?: string; recommendUserId?: string } = { userId: signForm.userId };
+    if (signForm.money) body.money = signForm.money;
+    // 老带新(type=2)才带推荐人(可空)
+    if (selectedActivity.value.type === 2 && signForm.recommendUserId) body.recommendUserId = signForm.recommendUserId;
+    const r = await signActivity(selectedActivity.value.id, body);
+    if (r.code === 200) {
+      ElMessage_('success', '报名成功');
+      signVisible.value = false;
+      void loadSigneds();
+      void fetchList(); // 刷新活动列表 signed 计数
+    } else {
+      // 3105 已报名 / 3106 老带新限一次:读 code 直显 message(业务分支,非成功)
+      ElMessage_('warning', r.message || `报名失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    signBusy.value = false;
+  }
+}
+/** 取消报名(端点 27;3107/3108 读 code 直显;成功后刷新整行 + 活动列表) */
+async function doCancel(s: ActivitySignDTO): Promise<void> {
+  if (!selectedActivity.value) return;
+  try {
+    await ElConfirm(`确认取消「${studentName(s.userId)}」的报名?取消后该报名金额将归零。`);
+  } catch {
+    return;
+  }
+  const r = await cancelSign(selectedActivity.value.id, { userId: s.userId });
+  if (r.code === 200) {
+    ElMessage_('success', '已取消报名');
+    void loadSigneds();
+    void fetchList();
+  } else {
+    ElMessage_('warning', r.message || `取消失败(code ${r.code})`);
+  }
+}
+
+const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消' };
 </script>
 
 <template>
   <div class="activity-page">
+    <!-- ① 活动列表(端点 22,浏览用) -->
     <ListPage
       v-model:page="page"
       v-model:page-size="pageSize"
-      :rows="rows"
+      :rows="activities"
       :total="total"
       :loading="loading"
       :error="error"
@@ -57,6 +178,68 @@ onMounted(() => {
         </el-select>
       </template>
     </ListPage>
+
+    <!-- ② 报名管理(端点 26/27/28) -->
+    <div class="activity-page__sign">
+      <div class="activity-page__sign-head">
+        <el-select v-model="selectedActivityId" placeholder="选择活动查看/管理报名" filterable class="activity-page__sign-select">
+          <el-option v-for="a in activities" :key="a.id" :value="a.id" :label="`${a.name}(${typeText[a.type]})`" />
+        </el-select>
+        <el-button type="primary" :disabled="!selectedActivityId" @click="openSign">新增报名</el-button>
+        <span v-if="selectedActivity" class="activity-page__sign-hint">
+          报名 {{ selectedActivity.signed }}/{{ selectedActivity.total }} · 活动金额 {{ selectedActivity.money }} · 状态 {{ statusText[selectedActivity.status] }}
+        </span>
+      </div>
+
+      <el-table v-if="selectedActivity" :data="signeds" :loading="signedLoading" size="small" border>
+        <el-table-column prop="id" label="报名 id" width="90" />
+        <el-table-column label="学生" min-width="140">
+          <template #default="{ row }">{{ studentName(row.userId) }}</template>
+        </el-table-column>
+        <el-table-column label="推荐人" min-width="120">
+          <template #default="{ row }">{{ row.recommendUserId ? studentName(row.recommendUserId) : '—' }}</template>
+        </el-table-column>
+        <el-table-column prop="money" label="报名金额" width="110" />
+        <el-table-column label="状态" width="90">
+          <template #default="{ row }">{{ signStatusText[row.status] ?? String(row.status) }}</template>
+        </el-table-column>
+        <el-table-column label="报名时间" width="160">
+          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="110" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" type="danger" :disabled="row.status !== 1" @click="doCancel(row)">取消报名</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-else description="选择活动后查看/管理报名" :image-size="80" />
+    </div>
+
+    <!-- 报名对话框(端点 26) -->
+    <el-dialog v-model="signVisible" title="新增报名" width="480px">
+      <el-form label-width="90px">
+        <el-form-item label="活动">
+          <span>{{ selectedActivity?.name }}({{ selectedActivity ? typeText[selectedActivity.type] : '' }})</span>
+        </el-form-item>
+        <el-form-item label="报名学生">
+          <el-select v-model="signForm.userId" filterable placeholder="选择学生" style="width: 100%">
+            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="报名金额">
+          <el-input v-model="signForm.money" placeholder="如 100.00(可留空)" />
+        </el-form-item>
+        <el-form-item v-if="selectedActivity?.type === 2" 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>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="signVisible = false">取消</el-button>
+        <el-button type="primary" :loading="signBusy" @click="doSign">确认报名</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -64,4 +247,22 @@ onMounted(() => {
 .activity-page {
   width: 100%;
 }
+.activity-page__sign {
+  margin-top: 16px;
+  padding-top: 12px;
+  border-top: 1px solid var(--app-color-border, #e3e7ea);
+}
+.activity-page__sign-head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+.activity-page__sign-select {
+  width: 320px;
+}
+.activity-page__sign-hint {
+  font-size: var(--app-font-sm);
+  color: var(--app-color-text-2, #5d6672);
+}
 </style>

+ 332 - 17
src/views/academic/divide/index.vue

@@ -1,43 +1,246 @@
 <script setup lang="ts">
 /**
- * 分账页(views/academic/divide/index.vue)—— 分账树(菜单「分账」,端点 38,树形非分页)。
- * el-table tree-props 渲染上级→下线层级(最多 3 层);quota 为金额(每次分成金额)。
+ * 分账页(views/academic/divide/index.vue)—— 分账树(端点 38)+ 写操作(端点 39/40)+ 分账流水(端点 41/42/43)。
+ *
+ * 结构(自上而下):
+ *   ① 分账树(el-table tree-props,端点 38;行内"设置额度""添加下线");
+ *   ② 设置额度对话框(端点 40,3111 父子额度约束:子额度 ≤ 父额度);
+ *   ③ 添加下线对话框(端点 39;新节点默认 quota=0.00 → 成功后引导设置额度);
+ *   ④ 分账流水(端点 43 列表 + 端点 41 新增 + 端点 42 删除,ListPage 分页)。
+ *
+ * 纪律(契约 v1.9 §2.3):
+ *   - 分账是树形(上级→下线,最多 3 层);quota 为**金额**(每次分成金额,0.00 = 该链不分成);
+ *   - 端点 39/40 角色 super_admin/academic;端点 41/42 同;端点 43 列表多一个 finance 可查;
+ *   - 3111(子额度>父额度 / 层级超限)读 code 直显 message;
+ *   - 写操作恒 200 + body code,读 code 判定;金额字符串两位小数;时间 RFC3339。
  */
-import { getDivideTree, type DivideNodeDTO } from '@/api/academic/divide-into';
+import { getDivideTree, addDivideMember, setDivideQuota, createDivideLog, deleteDivideLog, listDivideLogs } from '@/api/academic/divide-into';
+import type { DivideNodeDTO, DivideLogDTO } from '@/api/academic/divide-into';
+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 { formatMoney } from '@/utils/format';
+import { formatMoney, 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 studentOpts = ref<MemberDTO[]>([]);
+const studentName = (id: string | null): string => (id ? studentOpts.value.find((m) => m.userId === id)?.realName ?? id : '—');
+onMounted(async () => {
+  const r = await listMembers({ institutionId: scope.current.institutionId, personType: 'student', pageSize: 200 });
+  if (r.code === 200) studentOpts.value = r.data?.list ?? [];
+});
+
+/* ================= ① 分账树(端点 38) ================= */
 const tree = ref<DivideNodeDTO[]>([]);
-const loading = ref(false);
-const error = ref('');
+const treeLoading = ref(false);
+const treeError = ref('');
 
-async function load(): Promise<void> {
-  loading.value = true;
-  error.value = '';
+async function loadTree(): Promise<void> {
+  treeLoading.value = true;
+  treeError.value = '';
   try {
     const r = await getDivideTree(scope.toQuery());
     if (r.code === 200 && r.data) tree.value = r.data.tree;
-    else error.value = r.message || '加载失败';
+    else treeError.value = r.message || '加载失败';
   } catch {
-    error.value = '网络连接异常';
+    treeError.value = '网络连接异常';
   } finally {
-    loading.value = false;
+    treeLoading.value = false;
   }
 }
 onMounted(() => {
-  void load();
+  void loadTree();
 });
+
+/** 树拍平为「userId → 姓名」选项(添加下线/流水选分账人用;含全层级)。 */
+function flatten(nodes: DivideNodeDTO[]): { userId: string; realName: string }[] {
+  const out: { userId: string; realName: string }[] = [];
+  const walk = (ns: DivideNodeDTO[]): void => {
+    for (const n of ns) {
+      out.push({ userId: n.userId, realName: n.realName });
+      walk(n.children);
+    }
+  };
+  walk(nodes);
+  return out;
+}
+const treeMembers = computed(() => flatten(tree.value));
+
+/* ================= ② 设置额度(端点 40) ================= */
+const quotaVisible = ref(false);
+const quotaBusy = ref(false);
+const quotaTarget = ref<DivideNodeDTO | null>(null);
+const quotaForm = reactive({ quota: '' });
+function openQuota(node: DivideNodeDTO): void {
+  quotaTarget.value = node;
+  quotaForm.quota = node.quota;
+  quotaVisible.value = true;
+}
+async function doSetQuota(): Promise<void> {
+  const node = quotaTarget.value;
+  if (!node) return;
+  quotaBusy.value = true;
+  try {
+    const r = await setDivideQuota(node.userId, { quota: quotaForm.quota });
+    if (r.code === 200) {
+      ElMessage_('success', '额度已更新');
+      quotaVisible.value = false;
+      void loadTree();
+    } else {
+      // 3111 子额度>父额度 / 2002 格式错:读 code 直显
+      ElMessage_('warning', r.message || `设置失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    quotaBusy.value = false;
+  }
+}
+
+/* ================= ③ 添加下线(端点 39;新节点默认 quota=0.00 → 引导设额度) ================= */
+const memberVisible = ref(false);
+const memberBusy = ref(false);
+const memberTarget = ref<DivideNodeDTO | null>(null);
+const memberForm = reactive({ subUserId: '' });
+function openAddMember(node: DivideNodeDTO): void {
+  memberTarget.value = node;
+  memberForm.subUserId = studentOpts.value[0]?.userId ?? '';
+  memberVisible.value = true;
+}
+async function doAddMember(): Promise<void> {
+  const node = memberTarget.value;
+  if (!node) return;
+  if (!memberForm.subUserId) {
+    ElMessage_('warning', '请选择下线成员');
+    return;
+  }
+  memberBusy.value = true;
+  try {
+    const r = await addDivideMember({ parentUserId: node.userId, subUserId: memberForm.subUserId });
+    if (r.code === 200) {
+      // 契约 §2.3:新节点默认 quota=0.00,不设置额度则该链不分成且不报错 → 引导设置
+      ElMessage_('success', '下线已添加(默认额度 0.00,请尽快设置额度,否则该链不产生分成)');
+      memberVisible.value = false;
+      await loadTree();
+      void loadLogs();
+    } else {
+      // 3111 层级超限 / 3140 已在树中 / 3001 不存在:读 code 直显
+      ElMessage_('warning', r.message || `添加失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    memberBusy.value = false;
+  }
+}
+
+/* ================= ④ 分账流水(端点 41/42/43) ================= */
+const { rows: logs, total: logTotal, page: logPage, pageSize: logPageSize, loading: logLoading, error: logError, fetchList: loadLogs } =
+  useListPage<DivideLogDTO>({
+    fetcher: (p, ps) => listDivideLogs({ ...scope.toQuery(), page: p, pageSize: ps }),
+  });
+onMounted(() => {
+  void loadLogs();
+});
+const logColumns: ListColumn<DivideLogDTO>[] = [
+  { prop: 'id', label: '流水 id', minWidth: 90 },
+  {
+    prop: 'findUserId',
+    label: '分账人',
+    minWidth: 130,
+    formatter: (r) => {
+      const m = treeMembers.value.find((x) => x.userId === r.findUserId);
+      return m ? m.realName : r.findUserId;
+    },
+  },
+  {
+    prop: 'studentId',
+    label: '学生',
+    minWidth: 130,
+    formatter: (r) => studentName(r.studentId),
+  },
+  { prop: 'orderNo', label: '订单号', minWidth: 150 },
+  { prop: 'money', label: '金额', minWidth: 100 },
+  { prop: 'remark', label: '备注', minWidth: 140 },
+  { prop: 'createTime', label: '时间', minWidth: 160, formatter: (r) => formatDateTime(r.createTime) },
+];
+
+/** 记分账流水对话框(端点 41) */
+const logVisible = ref(false);
+const logBusy = ref(false);
+const logForm = reactive({ findUserId: '', studentId: '', orderNo: '', remark: '' });
+function openLog(): void {
+  logForm.findUserId = treeMembers.value[0]?.userId ?? '';
+  logForm.studentId = studentOpts.value[0]?.userId ?? '';
+  logForm.orderNo = `ORD${Date.now()}`;
+  logForm.remark = '';
+  logVisible.value = true;
+}
+async function doCreateLog(): Promise<void> {
+  if (!logForm.findUserId || !logForm.studentId || !logForm.orderNo) {
+    ElMessage_('warning', '请填全分账人/学生/订单号');
+    return;
+  }
+  logBusy.value = true;
+  try {
+    const r = await createDivideLog({ findUserId: logForm.findUserId, studentId: logForm.studentId, orderNo: logForm.orderNo, remark: logForm.remark || undefined });
+    if (r.code === 200) {
+      ElMessage_('success', '流水已记录');
+      logVisible.value = false;
+      void loadLogs();
+    } else {
+      ElMessage_('warning', r.message || `记录失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    logBusy.value = false;
+  }
+}
+/** 删分账流水(端点 42) */
+async function doDeleteLog(l: DivideLogDTO): Promise<void> {
+  try {
+    await ElConfirm(`确认删除订单 ${l.orderNo} 的分账流水?`);
+  } catch {
+    return;
+  }
+  const r = await deleteDivideLog({ studentId: l.studentId, orderNo: l.orderNo });
+  if (r.code === 200) {
+    ElMessage_('success', '已删除');
+    void loadLogs();
+  } else {
+    ElMessage_('warning', r.message || `删除失败(code ${r.code})`);
+  }
+}
 </script>
 
 <template>
   <div class="divide-page">
+    <!-- ① 分账树(端点 38) -->
     <div class="divide-page__bar">
-      <el-button @click="load" :loading="loading">刷新</el-button>
-      <span class="divide-page__hint">分账树:上级 → 下线(最多 3 层);额度 0.00 = 该链不分成</span>
+      <el-button @click="loadTree" :loading="treeLoading">刷新</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-alert v-if="treeError" :title="treeError" type="error" show-icon class="divide-page__error" />
+    <el-table v-else :data="tree" :loading="treeLoading" 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>
@@ -45,7 +248,98 @@ onMounted(() => {
       <el-table-column label="累计收入(元)" min-width="130">
         <template #default="{ row }">¥ {{ formatMoney(parseFloat(row.cumulativeIncome)) }}</template>
       </el-table-column>
+      <el-table-column label="操作" min-width="170" fixed="right">
+        <template #default="{ row }">
+          <el-button size="small" type="primary" @click="openQuota(row)">设置额度</el-button>
+          <el-button size="small" type="warning" @click="openAddMember(row)">添加下线</el-button>
+        </template>
+      </el-table-column>
     </el-table>
+
+    <!-- ④ 分账流水(端点 41/42/43) -->
+    <div class="divide-page__logs">
+      <ListPage
+        v-model:page="logPage"
+        v-model:page-size="logPageSize"
+        :rows="logs"
+        :total="logTotal"
+        :loading="logLoading"
+        :error="logError"
+        :columns="logColumns"
+      >
+        <template #toolbar>
+          <el-button type="primary" :disabled="treeMembers.length === 0" @click="openLog">记分账流水</el-button>
+        </template>
+      </ListPage>
+      <div class="divide-page__log-actions">
+        <div v-for="l in logs" :key="l.id" class="divide-page__log-row">
+          <span class="divide-page__log-no">{{ l.orderNo }}</span>
+          <el-button size="small" type="danger" @click="doDeleteLog(l)">删除</el-button>
+        </div>
+      </div>
+    </div>
+
+    <!-- ② 设置额度对话框(端点 40) -->
+    <el-dialog v-model="quotaVisible" title="设置分成额度" width="420px">
+      <el-form label-width="100px">
+        <el-form-item label="成员">
+          <span>{{ quotaTarget?.realName }}</span>
+        </el-form-item>
+        <el-form-item label="分成额度">
+          <el-input v-model="quotaForm.quota" placeholder="金额(如 100.00;0.00 = 不分成)" />
+        </el-form-item>
+        <el-alert title="子额度不可超过父额度(3111);额度变化影响该节点及其全部下线" type="info" :closable="false" />
+      </el-form>
+      <template #footer>
+        <el-button @click="quotaVisible = false">取消</el-button>
+        <el-button type="primary" :loading="quotaBusy" @click="doSetQuota">保存</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- ③ 添加下线对话框(端点 39) -->
+    <el-dialog v-model="memberVisible" title="添加下线" width="440px">
+      <el-form label-width="100px">
+        <el-form-item label="上级">
+          <span>{{ memberTarget?.realName }}</span>
+        </el-form-item>
+        <el-form-item label="下线成员">
+          <el-select v-model="memberForm.subUserId" filterable placeholder="选择成员" style="width: 100%">
+            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+          </el-select>
+        </el-form-item>
+        <el-alert title="新节点默认额度 0.00;添加成功后请设置额度,否则该链不产生分成(不报错)" type="info" :closable="false" />
+      </el-form>
+      <template #footer>
+        <el-button @click="memberVisible = false">取消</el-button>
+        <el-button type="primary" :loading="memberBusy" @click="doAddMember">添加</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 记分账流水对话框(端点 41) -->
+    <el-dialog v-model="logVisible" title="记分账流水" width="480px">
+      <el-form label-width="90px">
+        <el-form-item label="分账人">
+          <el-select v-model="logForm.findUserId" filterable placeholder="选择分账人(树内成员)" style="width: 100%">
+            <el-option v-for="m in treeMembers" :key="m.userId" :value="m.userId" :label="m.realName" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="学生">
+          <el-select v-model="logForm.studentId" filterable placeholder="选择学生" style="width: 100%">
+            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="订单号">
+          <el-input v-model="logForm.orderNo" placeholder="订单号" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="logForm.remark" placeholder="可空" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="logVisible = false">取消</el-button>
+        <el-button type="primary" :loading="logBusy" @click="doCreateLog">记录</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -63,4 +357,25 @@ onMounted(() => {
   font-size: var(--app-font-md);
   color: var(--app-text-secondary, #8a8f9c);
 }
+.divide-page__logs {
+  margin-top: 16px;
+  padding-top: 12px;
+  border-top: 1px solid var(--app-color-border, #e3e7ea);
+}
+.divide-page__log-actions {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.divide-page__log-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.divide-page__log-no {
+  font-size: var(--app-font-md);
+  color: var(--app-color-text-2, #5d6672);
+  min-width: 160px;
+}
 </style>