Explorar el Código

feat(payroll,expenditure): 批次 A 课酬方案页 + 收支页(契约 v1.9 两级结构)

新增 2 个操作页面 + api 封装 + 路由 + mock 菜单,对齐 academic 契约 v1.9:

课酬方案页(views/academic/payroll,端点 58/59):
- 两级结构(v1.9 通知 §1「页面一分为二」):方案列表 + 逐方案渲染明细表
- 新建/编辑对话框:上半方案表单(方案名/校区/生效日/方案级启停)+ 下半明细表格(行内编辑增删行)
- 保存 = 整案 + 完整 details 一起提交(POST 新建 / PUT 整体替换明细,同事务先删后插)
- 两个 actived 分开(方案级 actived / 明细级 detailActived);教龄两输入框(min/max 区间)
- 试算对话框(端点 59):只展示命中哪一档(9 字段含 4 溯源),不展示最终金额;
  3112 方案未配置 → 引导去配置(不当失败 toast);底部提示"勿当工资金额"

收支页(views/academic/expenditure,端点 60):
- 收支记录列表(type 1支出/2收入 筛选)+ 新增记录对话框(选字典+金额+记账人可空)
- 收支字典只读展示({id,type,name,parentId};POST /types 本轮不做,明示)
- v1.9 三修正落地:用 type 判方向(无 direction)、createUserId=记账人可空、expenditureId 回填字典

api 封装:
- 新增 api/academic/payroll.ts(listPlans/createPlan/updatePlan/quotePayroll + DTO)
- api/academic/finance.ts 补端点 60(listExpenditures/fetchExpenditureTypes/createExpenditure + DTO)

路由/mock:
- router 加 2 条(academic/payroll、academic/expenditure)
- mock auth FULL_MENU 加 2 条菜单(课酬方案、收支管理)

质量门禁:vue-tsc 0 错 / eslint 0 错 / vite build 0 错;
DTO↔mock↔页面 字段三方人工核对一致(mock handler 返回 R<unknown> 不经类型约束,须人工对齐)。
Q hace 3 horas
padre
commit
ac157b4817

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

@@ -259,3 +259,69 @@ export function rejectRequest(id: number, params: { institutionId: number; campu
 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);
 }
+
+/* ================= 收支记录 / 收支字典(端点 60;v1.9 三条修正) ================= */
+
+/** 收支记录 DTO(端点 60 列表行;记录表无 type/name/direction 列——type/name 由后端 JOIN 字典下发)。 */
+export interface ExpenditureRecordDTO {
+  /** 记录 id */
+  id: number;
+  /** 收支字典 id(← expenditure_id;编辑表单回填用) */
+  expenditureId: number;
+  /** 收支方向编码:1 = 支出 / 2 = 收入(用 type 判方向,两表无 direction 列) */
+  type: number;
+  /** 收支类型名(JOIN 字典 t_expenditure.name) */
+  name: string;
+  /** 金额(字符串两位小数) */
+  money: string;
+  /** 记账人(← create_user_id;语义 = 记账人,非"收付对象";可空) */
+  createUserId: string | null;
+  campusId: number;
+  /** 业务单号 */
+  bizNo: string;
+  /** 业务时间(RFC3339) */
+  createTime: string;
+}
+
+/** 收支记录查询参数(作用域由 useScope().toQuery() 注入;type 1 支出 / 2 收入) */
+export type ExpenditureQuery = {
+  institutionId: number;
+  campusId?: number;
+  type?: number;
+  page?: number;
+  pageSize?: number;
+};
+
+/** 收支记录分页列表(端点 60,GET /api/admin/expenditures)。 */
+export function listExpenditures(params: ExpenditureQuery): Promise<PageR<ExpenditureRecordDTO>> {
+  return http.get<PageR<ExpenditureRecordDTO>>('/api/admin/expenditures', { params }).then((r) => r.data);
+}
+
+/** 收支类型字典项(端点 60 GET /types 行) */
+export interface ExpenditureTypeDTO {
+  /** 字典 id(记录 expenditureId 指向它) */
+  id: number;
+  /** 收支方向编码:1 = 支出 / 2 = 收入 */
+  type: number;
+  name: string;
+  /** 父级字典 id(0 = 顶级) */
+  parentId: number;
+}
+
+/** 收支类型字典(端点 60,GET /api/admin/expenditures/types)。
+ *  ⚠ POST /types(新增收支类型)本轮不做——需要时提需求。 */
+export function fetchExpenditureTypes(params: { institutionId: number; campusId?: number }): Promise<R<{ types: ExpenditureTypeDTO[] }>> {
+  return http.get<R<{ types: ExpenditureTypeDTO[] }>>('/api/admin/expenditures/types', { params }).then((r) => r.data);
+}
+
+/** 新增收支记录(端点 60,POST /api/admin/expenditures)→ {id, bizNo}。
+ *  expenditureId 指向字典(type/name 由后端 JOIN 字典得出,前端不传);createUserId 可空。 */
+export function createExpenditure(body: {
+  expenditureId: number;
+  money: string;
+  campusId: number;
+  createUserId?: string | null;
+  institutionId: number;
+}): Promise<R<{ id: number; bizNo: string }>> {
+  return http.post<R<{ id: number; bizNo: string }>>('/api/admin/expenditures', body).then((r) => r.data);
+}

+ 139 - 0
src/api/academic/payroll.ts

@@ -0,0 +1,139 @@
+/**
+ * payroll.ts —— 课酬接口封装(academic 域;端点 58/59)。
+ *
+ * 契约对齐(academic-frontend-contract-v1 v1.9):
+ *   - 端点 58 课酬方案:**两级结构**(方案 + 明细列表);金额恒字符串两位小数;
+ *     percentage 是**百分数字符串**("85.0" = 85%,不是 0.85);
+ *     PUT /plans/:id 的 :id = **方案 id**,提交完整 details → 整体替换(同事务先删后插)。
+ *   - 端点 59 课酬试算:**只返回规则参数、不含最终金额**(9 字段,含 4 个溯源字段);
+ *     最终课酬只在下课时(端点 51)产生。3112 = 方案未配置 → 引导去配置。
+ *
+ * 典型使用(views/academic/payroll/index.vue):
+ *   - 方案列表:useListPage<PayrollPlanDTO> fetcher=listPlans
+ *   - 新建/编辑:createPlan / updatePlan(整案 + details 一起提交)
+ *   - 试算:quotePayroll → 展示"命中哪一档",**不要**当"这节课的工资"展示
+ */
+import { http } from '@/api/http';
+import type { PageR, R } from '@/types/api';
+
+/** 课酬明细(端点 58 行内 details[];对应 t_course_payment_detail) */
+export interface PayrollDetailDTO {
+  /** 明细 id(int64 主键;编辑时回传) */
+  detailId: number;
+  /** 老师 id(ULID 字符串;null = 通用档,按 classify/教龄 匹配) */
+  teacherId: string | null;
+  /** 科目分类 id */
+  classifyId: number;
+  /** 班型 */
+  classType: number;
+  /** 年级(-1 = 不限) */
+  gradeId: number;
+  /** 学科(-1 = 不限) */
+  subjectId: number;
+  /** 教师资格证要求(0 = 不限) */
+  teacherCert: number;
+  /** 教龄下限(年) */
+  teacherExpMin: number;
+  /** 教龄上限(年) */
+  teacherExpMax: number;
+  /** 课酬百分比(字符串,"85.0" = 85%;1 位小数) */
+  percentage: string;
+  /** 最低课酬标准(金额字符串;0 = 不限制) */
+  min: string;
+  /** 最高课酬标准(金额字符串;0 = 不限制) */
+  max: string;
+  /** 额外课酬(在比例基础上追加,金额字符串) */
+  reward: string;
+  /** 计算方式名(t_course_payment_detail.name;null = 未命名) */
+  displayName: string | null;
+  /** 明细级启停(0 停用 / 1 启用;某档停用 ≠ 整案停用) */
+  detailActived: number;
+}
+
+/** 课酬方案(端点 58 行;对应 t_course_payment + details) */
+export interface PayrollPlanDTO {
+  /** 方案 id(int64 主键;PUT /plans/:id 的 :id = 它) */
+  id: number;
+  institutionId: number;
+  /** 校区 id(0 = 机构级方案) */
+  campusId: number;
+  /** 方案名(t_course_payment.name) */
+  schemeName: string;
+  /** 生效日期(纯日期) */
+  effectDate: string;
+  /** 方案级启停(0 整案停用 / 1 启用) */
+  actived: number;
+  details: PayrollDetailDTO[];
+}
+
+/** 方案列表查询参数(作用域由 useScope().toQuery() 注入) */
+export type PlanQuery = {
+  institutionId: number;
+  campusId?: number;
+  actived?: number;
+  classifyId?: number;
+  teacherId?: string;
+  page?: number;
+  pageSize?: number;
+};
+
+/** 课酬方案分页列表(端点 58,GET /api/admin/payroll/plans)。 */
+export function listPlans(params: PlanQuery): Promise<PageR<PayrollPlanDTO>> {
+  return http.get<PageR<PayrollPlanDTO>>('/api/admin/payroll/plans', { params }).then((r) => r.data);
+}
+
+/** 新建方案入参(方案级字段 + 完整明细列表;至少 1 条明细)。 */
+export type PlanInput = {
+  institutionId: number;
+  campusId: number; // 0 = 机构级
+  schemeName: string;
+  effectDate: string;
+  actived: number;
+  details: PayrollDetailDTO[];
+};
+
+/** 新建课酬方案(端点 58,POST /api/admin/payroll/plans)→ {id}。 */
+export function createPlan(body: PlanInput): Promise<R<{ id: number }>> {
+  return http.post<R<{ id: number }>>('/api/admin/payroll/plans', body).then((r) => r.data);
+}
+
+/** 更新课酬方案(端点 58,PUT /api/admin/payroll/plans/:id;:id = 方案 id)。
+ *  details 传完整数组 → 整体替换(同事务先删后插),不是逐条 PATCH。 */
+export function updatePlan(id: number, body: PlanInput): Promise<R<{ id: number }>> {
+  return http.put<R<{ id: number }>>(`/api/admin/payroll/plans/${id}`, body).then((r) => r.data);
+}
+
+/** 课酬试算结果(端点 59;**只含规则参数,不含最终金额**;4 个溯源字段不得裁掉) */
+export interface PayrollQuoteResult {
+  /** 命中的课酬明细规则 id */
+  detailId: number;
+  /** 课酬百分比(百分数字符串,"65.5" = 65.5%) */
+  percentage: string;
+  /** 最低课酬标准(金额字符串;0 = 不限制) */
+  min: string;
+  /** 最高课酬标准(金额字符串;0 = 不限制) */
+  max: string;
+  /** 额外课酬(金额字符串) */
+  reward: string;
+  /** 命中的课酬方案 id */
+  schemeId: number;
+  /** 命中方案所属校区(0 = 机构级方案) */
+  schemeCampusId: number;
+  /** 方案层级:campus | institution */
+  schemeScope: string;
+  /** 规则层级:personal_classify | personal_grade_subject | cert_general | nocert_general */
+  ruleTier: string;
+}
+
+/** 课酬试算(端点 59,POST /api/admin/payroll/quote)。
+ *  3112 = 方案未配置(无生效方案 / 四级全未命中)→ 引导去配置。 */
+export function quotePayroll(body: {
+  teacherId: string;
+  schoolId: number;
+  campusId: number;
+  classifyId: number;
+  teacherExp: number;
+  date: string;
+}): Promise<R<PayrollQuoteResult>> {
+  return http.post<R<PayrollQuoteResult>>('/api/admin/payroll/quote', body).then((r) => r.data);
+}

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

@@ -40,6 +40,8 @@ const FULL_MENU: MenuNode[] = [
   { code: 'academic.schedule.view', label: '排班管理', path: '/academic/schedule' },
   { code: 'academic.activity.view', label: '活动', path: '/academic/activity' },
   { code: 'academic.divide.view', label: '分账', path: '/academic/divide' },
+  { code: 'academic.payroll.view', label: '课酬方案', path: '/academic/payroll' },
+  { code: 'academic.expenditure.view', label: '收支管理', path: '/academic/expenditure' },
   { code: 'teaching.exam.view', label: '题库 / 组卷', path: '/teaching/exam' },
   { code: 'teaching.analysis.view', label: '练习分析', path: '/teaching/analysis' },
   { code: 'system.user.view', label: '用户 / 角色', path: '/system/users' },

+ 12 - 0
src/router/index.ts

@@ -107,6 +107,18 @@ export const routes: RouteRecordRaw[] = [
         component: () => import('@/views/academic/divide/index.vue'),
         meta: { title: '分账' },
       },
+      {
+        path: 'academic/payroll',
+        name: 'Payroll',
+        component: () => import('@/views/academic/payroll/index.vue'),
+        meta: { title: '课酬方案' },
+      },
+      {
+        path: 'academic/expenditure',
+        name: 'Expenditure',
+        component: () => import('@/views/academic/expenditure/index.vue'),
+        meta: { title: '收支管理' },
+      },
       {
         path: 'teaching/exam',
         name: 'TeachingExam',

+ 210 - 0
src/views/academic/expenditure/index.vue

@@ -0,0 +1,210 @@
+<script setup lang="ts">
+/**
+ * 收支页(views/academic/expenditure/index.vue)—— 对齐契约 v1.9 端点 60(收支记录 + 收支字典)。
+ *
+ * 结构:
+ *   ① 收支记录(端点 60 GET,useListPage 分页 + type 筛选:1 支出 / 2 收入);
+ *   ② 新增收支记录对话框(选字典类型 + 金额 + 记账人可空)→ POST → {id, bizNo};
+ *   ③ 收支字典(端点 60 GET /types;{id, type, name, parentId};**POST /types 本轮不做**,只读展示)。
+ *
+ * v1.9 三条修正(本页面直接体现):
+ *   - 用 `type` 判方向(1 支出 / 2 收入),**没有 direction 列**;
+ *   - `createUserId` = **记账人**(← create_user_id,可空),不是"收付对象";
+ *   - `expenditureId` 回填字典,编辑表单可定位类型。
+ */
+import { listExpenditures, fetchExpenditureTypes, createExpenditure } from '@/api/academic/finance';
+import type { ExpenditureRecordDTO, ExpenditureTypeDTO } 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);
+}
+
+/* ================= 选项数据(校区/记账人) ================= */
+const campusOpts = ref<CampusDTO[]>([]);
+const memberOpts = ref<MemberDTO[]>([]);
+const campusName = (id: number): string => campusOpts.value.find((c) => c.campusId === id)?.campusName ?? `#${id}`;
+
+onMounted(async () => {
+  const q = scope.toQuery();
+  const [c, m] = await Promise.all([
+    listCampuses({ ...q, pageSize: 200 }),
+    listMembers({ ...q, pageSize: 200 }),
+  ]);
+  if (c.code === 200) campusOpts.value = c.data?.list ?? [];
+  if (m.code === 200) memberOpts.value = m.data?.list ?? [];
+});
+
+/* ================= 收支记录(端点 60 GET) ================= */
+const typeFilter = ref<number | undefined>(undefined);
+const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<ExpenditureRecordDTO>({
+  fetcher: (p, ps) => listExpenditures({ ...scope.toQuery(), type: typeFilter.value, page: p, pageSize: ps }),
+});
+onMounted(() => {
+  void fetchList();
+});
+
+const typeText: Record<number, string> = { 1: '支出', 2: '收入' };
+const columns: ListColumn<ExpenditureRecordDTO>[] = [
+  { prop: 'bizNo', label: '单号', minWidth: 140 },
+  { prop: 'type', label: '方向', minWidth: 80, formatter: (r) => typeText[r.type] ?? String(r.type) },
+  { prop: 'name', label: '类型', minWidth: 140 },
+  { prop: 'money', label: '金额', minWidth: 110, formatter: (r) => `¥ ${r.money}` },
+  { prop: 'createUserId', label: '记账人', minWidth: 120, formatter: (r) => (r.createUserId ? r.createUserId.slice(0, 13) + '…' : '—') },
+  { prop: 'campusId', label: '校区', minWidth: 100, formatter: (r) => campusName(r.campusId) },
+  { prop: 'createTime', label: '时间', minWidth: 150, formatter: (r) => formatDateTime(r.createTime) },
+];
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+
+/* ================= 收支字典(端点 60 GET /types;只读,POST /types 本轮不做) ================= */
+const typeDict = ref<ExpenditureTypeDTO[]>([]);
+async function loadTypes(): Promise<void> {
+  const r = await fetchExpenditureTypes({ ...scope.toQuery(), campusId: selectedCampusId.value || undefined });
+  if (r.code === 200 && r.data) typeDict.value = r.data.types;
+}
+onMounted(() => {
+  void loadTypes();
+});
+
+/* ================= 新增收支记录(端点 60 POST) ================= */
+const addVisible = ref(false);
+const addBusy = ref(false);
+const selectedCampusId = ref<number>(0);
+const addForm = reactive({ expenditureId: 0, money: '', createUserId: '' });
+
+function openAdd(): void {
+  addForm.expenditureId = 0;
+  addForm.money = '';
+  addForm.createUserId = '';
+  selectedCampusId.value = scope.current.campusId || 0;
+  addVisible.value = true;
+}
+async function doAdd(): Promise<void> {
+  if (!addForm.expenditureId) {
+    ElMessage_('warning', '请选择收支类型');
+    return;
+  }
+  if (!addForm.money) {
+    ElMessage_('warning', '请填写金额');
+    return;
+  }
+  addBusy.value = true;
+  try {
+    const r = await createExpenditure({
+      expenditureId: addForm.expenditureId,
+      money: addForm.money,
+      campusId: selectedCampusId.value,
+      createUserId: addForm.createUserId || null,
+      institutionId: scope.current.institutionId,
+    });
+    if (r.code === 200) {
+      ElMessage_('success', `新增成功(${r.data?.bizNo ?? ''})`);
+      addVisible.value = false;
+      void fetchList();
+    } else {
+      ElMessage_('warning', r.message || `新增失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    addBusy.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="expenditure-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-select v-model="typeFilter" placeholder="方向" clearable class="toolbar-select">
+          <el-option :value="1" label="支出" />
+          <el-option :value="2" label="收入" />
+        </el-select>
+        <el-button type="primary" @click="openAdd">新增收支记录</el-button>
+      </template>
+    </ListPage>
+
+    <!-- ③ 收支字典(只读;POST /types 本轮不做) -->
+    <div class="expenditure-page__dict">
+      <div class="expenditure-page__dict-head">收支类型字典(只读)</div>
+      <el-table :data="typeDict" size="small" border>
+        <el-table-column prop="id" label="字典 id" width="90" />
+        <el-table-column label="方向" width="80">
+          <template #default="{ row }">{{ typeText[row.type] ?? row.type }}</template>
+        </el-table-column>
+        <el-table-column prop="name" label="类型名" min-width="160" />
+        <el-table-column label="上级" width="120">
+          <template #default="{ row }">{{ row.parentId === 0 ? '顶级' : `#${row.parentId}` }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- ② 新增收支记录对话框 -->
+    <el-dialog v-model="addVisible" title="新增收支记录" width="480px">
+      <el-form label-width="90px">
+        <el-form-item label="收支类型">
+          <el-select v-model="addForm.expenditureId" style="width: 100%">
+            <el-option v-for="t in typeDict" :key="t.id" :value="t.id" :label="`${typeText[t.type]} · ${t.name}`" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="金额">
+          <el-input v-model="addForm.money" placeholder="如 100.00" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="校区">
+          <el-select v-model="selectedCampusId" style="width: 100%">
+            <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="记账人">
+          <el-select v-model="addForm.createUserId" clearable placeholder="可空" style="width: 100%">
+            <el-option v-for="m in memberOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="addVisible = false">取消</el-button>
+        <el-button type="primary" :loading="addBusy" @click="doAdd">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.expenditure-page {
+  width: 100%;
+}
+.expenditure-page__dict {
+  margin-top: 16px;
+}
+.expenditure-page__dict-head {
+  font-size: var(--app-font-md);
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+</style>

+ 541 - 0
src/views/academic/payroll/index.vue

@@ -0,0 +1,541 @@
+<script setup lang="ts">
+/**
+ * 课酬方案页(views/academic/payroll/index.vue)—— 对齐契约 v1.9 两级结构(端点 58/59)。
+ *
+ * 结构(v1.9 通知 §1「页面一分为二」):
+ *   ① 方案列表(端点 58 GET,useListPage 分页)+ 展开行显示**明细**(两级结构:
+ *      上半 = 方案行,下半 = details[]);
+ *   ② 新建/编辑方案对话框:上半 = 方案表单(方案名/校区/生效日/方案级启停),
+ *      下半 = 明细表格(行内编辑 + 增删行);
+ *      **保存 = 整案 + 完整 details 一起提交**(POST 新建 / PUT 整体替换明细,同事务先删后插);
+ *   ③ 试算对话框(端点 59):只展示「命中哪一档规则」(9 字段含 4 个溯源字段),
+ *      **不展示最终金额**(最终课酬只在下课时端点 51 产生;3112 = 方案未配置 → 引导去配置)。
+ *
+ * 纪律:
+ *   - 两个 actived 分开:方案级 `actived`(整案停用)/ 明细级 `detailActived`(某档停用);
+ *   - 教龄是两个输入框(下限/上限),`teacherExpMin`/`teacherExpMax` 区间;
+ *   - `percentage` 是百分数字符串("85.0" = 85%);min/max/reward 是金额字符串;
+ *   - 老师 `teacherId` 可空 = 通用档(按 classify/教龄 匹配);
+ *   - 写操作恒 200 + body code,业务错误(2001/2002/3001/3112…)读 code 直显 message。
+ */
+import { listPlans, createPlan, updatePlan, quotePayroll } from '@/api/academic/payroll';
+import type { PayrollPlanDTO, PayrollDetailDTO, PayrollQuoteResult } from '@/api/academic/payroll';
+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';
+
+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);
+}
+
+/* ================= 选项数据(校区/老师) ================= */
+const campusOpts = ref<CampusDTO[]>([]);
+const teacherOpts = ref<MemberDTO[]>([]);
+const campusName = (id: number): string => (id === 0 ? '机构级' : campusOpts.value.find((c) => c.campusId === id)?.campusName ?? `#${id}`);
+const teacherName = (id: string | null): string => (id ? teacherOpts.value.find((t) => t.userId === id)?.realName ?? id : '通用档');
+
+onMounted(async () => {
+  const q = scope.toQuery();
+  const [c, m] = await Promise.all([
+    listCampuses({ ...q, pageSize: 200 }),
+    listMembers({ ...q, personType: 'staff', pageSize: 200 }),
+  ]);
+  if (c.code === 200) campusOpts.value = c.data?.list ?? [];
+  if (m.code === 200) teacherOpts.value = m.data?.list ?? [];
+});
+
+/* ================= 方案列表(端点 58 GET) ================= */
+const activedFilter = ref<number | undefined>(undefined);
+const { rows: plans, total, page, pageSize, loading, error, fetchList } = useListPage<PayrollPlanDTO>({
+  fetcher: (p, ps) => listPlans({ ...scope.toQuery(), actived: activedFilter.value, page: p, pageSize: ps }),
+});
+onMounted(() => {
+  void fetchList();
+});
+
+/** 科目分类(新契约无独立字典端点,暂静态枚举;classifyId ← 旧 t_course_classify) */
+const CLASSIFIES = [1, 2, 3] as const;
+const classifyText: Record<number, string> = { 1: '数学', 2: '语文', 3: '英语' };
+const tierText: Record<string, string> = {
+  personal_classify: '个人·科目档',
+  personal_grade_subject: '个人·年级学科档',
+  cert_general: '持证通用档',
+  nocert_general: '无证通用档',
+};
+const statusText: Record<number, string> = { 0: '停用', 1: '启用' };
+
+const planColumns: ListColumn<PayrollPlanDTO>[] = [
+  { prop: 'id', label: '方案 id', minWidth: 80 },
+  { prop: 'schemeName', label: '方案名', minWidth: 200 },
+  { prop: 'campusId', label: '校区', minWidth: 120, formatter: (r) => campusName(r.campusId) },
+  { prop: 'effectDate', label: '生效日', minWidth: 110 },
+  { prop: 'actived', label: '方案状态', minWidth: 90, formatter: (r) => statusText[r.actived] ?? String(r.actived) },
+  { prop: 'details', label: '明细数', minWidth: 80, formatter: (r) => String(r.details?.length ?? 0) },
+];
+function onSearch(): void {
+  page.value = 1;
+  void fetchList();
+}
+
+/* ================= 方案编辑(新建/整体替换) ================= */
+/** 明细编辑行(teacherId 用 '' 表示通用档/null,提交时转 null) */
+interface DetailEditRow {
+  detailId: number;
+  teacherId: string; // '' = 通用档(null)
+  classifyId: number;
+  classType: number;
+  gradeId: number;
+  subjectId: number;
+  teacherCert: number;
+  teacherExpMin: number;
+  teacherExpMax: number;
+  percentage: string; // "85.0"
+  min: string;
+  max: string;
+  reward: string;
+  displayName: string;
+  detailActived: number;
+}
+let detailSeq = 1000;
+
+const editVisible = ref(false);
+const editBusy = ref(false);
+const editingId = ref<number | null>(null); // null = 新建
+const planForm = reactive({ campusId: 0, schemeName: '', effectDate: '2026-09-01', actived: 1 });
+const detailRows = ref<DetailEditRow[]>([]);
+
+function blankDetail(): DetailEditRow {
+  detailSeq += 1;
+  return {
+    detailId: detailSeq,
+    teacherId: '',
+    classifyId: 1,
+    classType: 1,
+    gradeId: -1,
+    subjectId: -1,
+    teacherCert: 0,
+    teacherExpMin: 0,
+    teacherExpMax: 100,
+    percentage: '80.0',
+    min: '0.00',
+    max: '0.00',
+    reward: '0.00',
+    displayName: '',
+    detailActived: 1,
+  };
+}
+function openCreate(): void {
+  editingId.value = null;
+  planForm.campusId = scope.current.campusId || 0;
+  planForm.schemeName = '';
+  planForm.effectDate = '2026-09-01';
+  planForm.actived = 1;
+  detailRows.value = [blankDetail()];
+  editVisible.value = true;
+}
+function openEdit(p: PayrollPlanDTO): void {
+  editingId.value = p.id;
+  planForm.campusId = p.campusId;
+  planForm.schemeName = p.schemeName;
+  planForm.effectDate = p.effectDate;
+  planForm.actived = p.actived;
+  detailRows.value = p.details.map((d) => ({
+    detailId: d.detailId,
+    teacherId: d.teacherId ?? '',
+    classifyId: d.classifyId,
+    classType: d.classType,
+    gradeId: d.gradeId,
+    subjectId: d.subjectId,
+    teacherCert: d.teacherCert,
+    teacherExpMin: d.teacherExpMin,
+    teacherExpMax: d.teacherExpMax,
+    percentage: d.percentage,
+    min: d.min,
+    max: d.max,
+    reward: d.reward,
+    displayName: d.displayName ?? '',
+    detailActived: d.detailActived,
+  }));
+  editVisible.value = true;
+}
+function addDetail(): void {
+  detailRows.value.push(blankDetail());
+}
+function removeDetail(i: number): void {
+  detailRows.value.splice(i, 1);
+}
+
+/** 提交:方案级字段 + 完整 details → 整体替换(PUT 同事务先删后插)。 */
+async function savePlan(): Promise<void> {
+  if (!planForm.schemeName) {
+    ElMessage_('warning', '请填写方案名');
+    return;
+  }
+  if (detailRows.value.length === 0) {
+    ElMessage_('warning', '至少需要 1 条明细');
+    return;
+  }
+  for (const d of detailRows.value) {
+    if (d.teacherExpMin > d.teacherExpMax) {
+      ElMessage_('warning', '明细教龄下限不能大于上限');
+      return;
+    }
+  }
+  const details: PayrollDetailDTO[] = detailRows.value.map((d) => ({
+    detailId: d.detailId,
+    teacherId: d.teacherId || null,
+    classifyId: d.classifyId,
+    classType: d.classType,
+    gradeId: d.gradeId,
+    subjectId: d.subjectId,
+    teacherCert: d.teacherCert,
+    teacherExpMin: d.teacherExpMin,
+    teacherExpMax: d.teacherExpMax,
+    percentage: d.percentage,
+    min: d.min,
+    max: d.max,
+    reward: d.reward,
+    displayName: d.displayName || null,
+    detailActived: d.detailActived,
+  }));
+  editBusy.value = true;
+  try {
+    const body = {
+      institutionId: scope.current.institutionId,
+      campusId: planForm.campusId,
+      schemeName: planForm.schemeName,
+      effectDate: planForm.effectDate,
+      actived: planForm.actived,
+      details,
+    };
+    const r = editingId.value === null ? await createPlan(body) : await updatePlan(editingId.value, body);
+    if (r.code === 200) {
+      ElMessage_('success', '保存成功');
+      editVisible.value = false;
+      void fetchList();
+    } else {
+      ElMessage_('warning', r.message || `保存失败(code ${r.code})`);
+    }
+  } catch {
+    ElMessage_('error', '网络连接异常');
+  } finally {
+    editBusy.value = false;
+  }
+}
+
+/* ================= 课酬试算(端点 59;只回规则参数,不含最终金额) ================= */
+const quoteVisible = ref(false);
+const quoteBusy = ref(false);
+const quoteForm = reactive({ teacherId: '', classifyId: 1, teacherExp: 0, date: '' });
+const quoteResult = ref<PayrollQuoteResult | null>(null);
+const quoteError = ref('');
+
+function openQuote(): void {
+  quoteResult.value = null;
+  quoteError.value = '';
+  if (teacherOpts.value.length > 0 && !quoteForm.teacherId) quoteForm.teacherId = teacherOpts.value[0].userId;
+  const today = new Date();
+  quoteForm.date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+  quoteVisible.value = true;
+}
+async function doQuote(): Promise<void> {
+  if (!quoteForm.teacherId) {
+    ElMessage_('warning', '请选择老师');
+    return;
+  }
+  quoteBusy.value = true;
+  quoteError.value = '';
+  quoteResult.value = null;
+  try {
+    const r = await quotePayroll({
+      teacherId: quoteForm.teacherId,
+      schoolId: scope.current.institutionId,
+      campusId: scope.current.campusId || 0,
+      classifyId: quoteForm.classifyId,
+      teacherExp: quoteForm.teacherExp,
+      date: quoteForm.date,
+    });
+    if (r.code === 200 && r.data) {
+      quoteResult.value = r.data;
+    } else if (r.code === 3112) {
+      // 3112:方案未配置 → 引导去配置(不得当失败 toast,是可操作的业务分支)
+      quoteError.value = '课酬方案未配置(3112):请先为该老师该科目配置生效方案';
+    } else {
+      quoteError.value = r.message || `试算失败(code ${r.code})`;
+    }
+  } catch {
+    quoteError.value = '网络连接异常';
+  } finally {
+    quoteBusy.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="payroll-page">
+    <!-- ① 方案列表(端点 58 GET;两级结构:下方区域逐方案渲染明细表) -->
+    <ListPage
+      v-model:page="page"
+      v-model:page-size="pageSize"
+      :rows="plans"
+      :total="total"
+      :loading="loading"
+      :error="error"
+      :columns="planColumns"
+      @search="onSearch"
+    >
+      <template #toolbar>
+        <el-select v-model="activedFilter" placeholder="方案状态" clearable class="toolbar-select">
+          <el-option :value="1" label="启用" />
+          <el-option :value="0" label="停用" />
+        </el-select>
+        <el-button type="primary" @click="openCreate">新建方案</el-button>
+        <el-button @click="openQuote">课酬试算</el-button>
+      </template>
+    </ListPage>
+
+    <!-- ①' 方案明细(v1.9 两级结构:方案行 + 明细表;编辑入口与方案一一对应) -->
+    <div v-for="p in plans" :key="p.id" class="payroll-page__plan">
+      <div class="payroll-page__plan-head">
+        <span class="payroll-page__plan-title">{{ p.schemeName }}({{ campusName(p.campusId) }},生效 {{ p.effectDate }},{{ statusText[p.actived] }})</span>
+        <el-button size="small" type="primary" @click="openEdit(p)">编辑方案(整体替换明细)</el-button>
+      </div>
+      <el-table :data="p.details" size="small" border class="payroll-page__details">
+        <el-table-column prop="detailId" label="明细 id" width="80" />
+        <el-table-column label="适用老师" min-width="120">
+          <template #default="{ row: d }">{{ teacherName(d.teacherId) }}</template>
+        </el-table-column>
+        <el-table-column label="科目" width="80">
+          <template #default="{ row: d }">{{ classifyText[d.classifyId] ?? d.classifyId }}</template>
+        </el-table-column>
+        <el-table-column label="年级/学科" width="110">
+          <template #default="{ row: d }">
+            {{ d.gradeId === -1 ? '不限' : `年级${d.gradeId}` }} / {{ d.subjectId === -1 ? '不限' : `学科${d.subjectId}` }}
+          </template>
+        </el-table-column>
+        <el-table-column label="持证" width="70">
+          <template #default="{ row: d }">{{ d.teacherCert === 0 ? '不限' : d.teacherCert }}</template>
+        </el-table-column>
+        <el-table-column label="教龄(年)" width="110">
+          <template #default="{ row: d }">{{ d.teacherExpMin }} ~ {{ d.teacherExpMax }}</template>
+        </el-table-column>
+        <el-table-column prop="percentage" label="比例(%)" width="90" />
+        <el-table-column label="下限/上限" width="150">
+          <template #default="{ row: d }">{{ d.min }} / {{ d.max }}</template>
+        </el-table-column>
+        <el-table-column prop="reward" label="额外" width="90" />
+        <el-table-column prop="displayName" label="计算方式名" min-width="140" />
+        <el-table-column label="档位状态" width="90">
+          <template #default="{ row: d }">{{ d.detailActived === 1 ? '启用' : '停用' }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- ② 方案编辑对话框(上半方案表单 + 下半明细表格) -->
+    <el-dialog v-model="editVisible" :title="editingId === null ? '新建课酬方案' : `编辑方案 #${editingId}`" width="980px">
+      <el-form label-width="90px" class="payroll-page__form">
+        <el-row :gutter="12">
+          <el-col :span="8">
+            <el-form-item label="方案名">
+              <el-input v-model="planForm.schemeName" placeholder="如:2026 秋季课酬方案" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="校区">
+              <el-select v-model="planForm.campusId" style="width: 100%">
+                <el-option :value="0" label="机构级(全校区)" />
+                <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="生效日">
+              <el-date-picker v-model="planForm.effectDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="方案启停">
+              <el-switch v-model="planForm.actived" :active-value="1" :inactive-value="0" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+
+      <div class="payroll-page__detail-head">
+        <span>明细(保存时整体替换;老师留空 = 通用档)</span>
+        <el-button size="small" type="primary" @click="addDetail">加一行</el-button>
+      </div>
+      <el-table :data="detailRows" size="small" border max-height="360">
+        <el-table-column label="适用老师" min-width="140">
+          <template #default="{ row: d }">
+            <el-select v-model="d.teacherId" size="small" clearable placeholder="通用档" style="width: 100%">
+              <el-option v-for="t in teacherOpts" :key="t.userId" :value="t.userId" :label="t.realName" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="科目" width="100">
+          <template #default="{ row: d }">
+            <el-select v-model="d.classifyId" size="small" style="width: 100%">
+              <el-option v-for="k in CLASSIFIES" :key="k" :value="k" :label="classifyText[k]" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="年级(-1不限)" width="100">
+          <template #default="{ row: d }">
+            <el-input-number v-model="d.gradeId" :min="-1" :max="12" size="small" controls-position="right" style="width: 88px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="学科(-1不限)" width="100">
+          <template #default="{ row: d }">
+            <el-input-number v-model="d.subjectId" :min="-1" :max="12" size="small" controls-position="right" style="width: 88px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="持证(0不限)" width="96">
+          <template #default="{ row: d }">
+            <el-input-number v-model="d.teacherCert" :min="0" :max="1" size="small" controls-position="right" style="width: 84px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="教龄下限" width="96">
+          <template #default="{ row: d }">
+            <el-input-number v-model="d.teacherExpMin" :min="0" :step="0.1" size="small" controls-position="right" style="width: 88px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="教龄上限" width="96">
+          <template #default="{ row: d }">
+            <el-input-number v-model="d.teacherExpMax" :min="0" :step="0.1" size="small" controls-position="right" style="width: 88px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="比例%" width="96">
+          <template #default="{ row: d }">
+            <el-input v-model="d.percentage" size="small" placeholder="85.0" style="width: 84px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="下限金额" width="104">
+          <template #default="{ row: d }">
+            <el-input v-model="d.min" size="small" placeholder="0.00" style="width: 92px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="上限金额" width="104">
+          <template #default="{ row: d }">
+            <el-input v-model="d.max" size="small" placeholder="0.00" style="width: 92px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="额外" width="96">
+          <template #default="{ row: d }">
+            <el-input v-model="d.reward" size="small" placeholder="0.00" style="width: 84px" />
+          </template>
+        </el-table-column>
+        <el-table-column label="计算方式名" min-width="130">
+          <template #default="{ row: d }">
+            <el-input v-model="d.displayName" size="small" placeholder="如:数学 5~15 年教龄" />
+          </template>
+        </el-table-column>
+        <el-table-column label="档位启停" width="80">
+          <template #default="{ row: d }">
+            <el-switch v-model="d.detailActived" :active-value="1" :inactive-value="0" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="" width="60">
+          <template #default="{ $index }">
+            <el-button size="small" type="danger" link @click="removeDetail($index)">删</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="editVisible = false">取消</el-button>
+        <el-button type="primary" :loading="editBusy" @click="savePlan">保存(整体替换明细)</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- ③ 课酬试算对话框(只回规则参数,不含最终金额;3112 引导配置) -->
+    <el-dialog v-model="quoteVisible" title="课酬试算" width="560px">
+      <el-form label-width="90px" class="payroll-page__form">
+        <el-form-item label="老师">
+          <el-select v-model="quoteForm.teacherId" style="width: 240px">
+            <el-option v-for="t in teacherOpts" :key="t.userId" :value="t.userId" :label="t.realName" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="科目">
+          <el-select v-model="quoteForm.classifyId" style="width: 160px">
+            <el-option v-for="k in CLASSIFIES" :key="k" :value="k" :label="classifyText[k]" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="教龄(年)">
+          <el-input-number v-model="quoteForm.teacherExp" :min="0" :step="1" style="width: 160px" />
+        </el-form-item>
+        <el-form-item label="日期">
+          <el-date-picker v-model="quoteForm.date" type="date" value-format="YYYY-MM-DD" style="width: 240px" />
+        </el-form-item>
+      </el-form>
+      <el-alert v-if="quoteError" :title="quoteError" type="warning" show-icon class="payroll-page__quote-err" />
+      <el-descriptions v-else-if="quoteResult" :column="2" border size="small" class="payroll-page__quote">
+        <el-descriptions-item label="命中方案">#{{ quoteResult.schemeId }}({{ quoteResult.schemeScope === 'institution' ? '机构级' : '校区级' }})</el-descriptions-item>
+        <el-descriptions-item label="规则档">{{ tierText[quoteResult.ruleTier] ?? quoteResult.ruleTier }}</el-descriptions-item>
+        <el-descriptions-item label="比例">{{ quoteResult.percentage }}%</el-descriptions-item>
+        <el-descriptions-item label="额外">{{ quoteResult.reward }}</el-descriptions-item>
+        <el-descriptions-item label="下限">{{ quoteResult.min }}</el-descriptions-item>
+        <el-descriptions-item label="上限">{{ quoteResult.max }}</el-descriptions-item>
+        <el-descriptions-item label="明细 id">{{ quoteResult.detailId }}</el-descriptions-item>
+        <el-descriptions-item label="方案校区">{{ quoteResult.schemeCampusId === 0 ? '机构级' : quoteResult.schemeCampusId }}</el-descriptions-item>
+      </el-descriptions>
+      <el-alert
+        v-if="quoteResult"
+        title="试算只返回规则参数;最终课酬在下课时(端点 51)产生,勿把本结果当工资金额展示"
+        type="info"
+        :closable="false"
+        class="payroll-page__quote-hint"
+      />
+      <template #footer>
+        <el-button @click="quoteVisible = false">关闭</el-button>
+        <el-button type="primary" :loading="quoteBusy" @click="doQuote">试算</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.payroll-page {
+  width: 100%;
+}
+.payroll-page__form {
+  margin-bottom: 12px;
+}
+.payroll-page__plan {
+  margin-top: 12px;
+}
+.payroll-page__plan-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 6px;
+}
+.payroll-page__plan-title {
+  font-size: var(--app-font-md);
+  font-weight: 600;
+}
+.payroll-page__details {
+  width: 100%;
+}
+.payroll-page__detail-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  font-size: var(--app-font-md);
+  color: var(--app-text-secondary, #8a8f9c);
+}
+.payroll-page__quote-err,
+.payroll-page__quote-hint {
+  margin-bottom: 12px;
+}
+</style>