Ver código fonte

前后端联调V1.0.0

DESKTOP-6LTVLN7\Liumouren 2 meses atrás
pai
commit
2b4098cdcd
41 arquivos alterados com 5743 adições e 918 exclusões
  1. 17 3
      README.md
  2. 181 0
      docs/api-integration.md
  3. 194 378
      package-lock.json
  4. 14 9
      package.json
  5. 14 7
      public/config/config.js
  6. 24 0
      scripts/fix-biz-braces.mjs
  7. 111 0
      scripts/patch-biz-real.mjs
  8. 269 77
      src/api/biz.ts
  9. 3 3
      src/api/mock/data.ts
  10. 419 0
      src/api/sjnmtybt/mappers.ts
  11. 1681 0
      src/api/sjnmtybt/realBiz.ts
  12. 111 0
      src/api/sjnmtybt/regionStore.ts
  13. 62 62
      src/auto-imports.d.ts
  14. 6 6
      src/components.d.ts
  15. 419 0
      src/components/biz/BatchDetailDrawer.vue
  16. 146 10
      src/composables/useTownVillageOptions.ts
  17. 18 0
      src/constants/batchActions.ts
  18. 5 0
      src/constants/menus.ts
  19. 165 22
      src/layouts/MainLayout.vue
  20. 3 1
      src/router/index.ts
  21. 24 0
      src/router/routes.ts
  22. 171 10
      src/stores/user.ts
  23. 56 4
      src/types/biz.ts
  24. 7 4
      src/utils/appConfig.ts
  25. 3 2
      src/utils/mask.ts
  26. 15 2
      src/utils/request.ts
  27. 177 0
      src/views/admin/AdminBatchesView.vue
  28. 208 0
      src/views/admin/PersonnelAdminView.vue
  29. 251 0
      src/views/admin/RegionsAdminView.vue
  30. 10 5
      src/views/alerts/AlertsView.vue
  31. 131 27
      src/views/approvals/ApprovalsView.vue
  32. 304 29
      src/views/base/BaseInfoView.vue
  33. 120 11
      src/views/dashboard/DashboardView.vue
  34. 55 9
      src/views/disk/DiskReturnView.vue
  35. 41 7
      src/views/insurance/InsuranceMatchView.vue
  36. 61 14
      src/views/monthly/MonthlyPaymentView.vue
  37. 86 102
      src/views/reports/ReportsView.vue
  38. 29 23
      src/views/special/SpecialBusinessView.vue
  39. 2 2
      src/views/status/StatusMaintenanceView.vue
  40. 1 1
      tsconfig.app.json
  41. 129 88
      vite.config.ts

+ 17 - 3
README.md

@@ -19,6 +19,18 @@ npm install
 npm run dev
 ```
 
+开发默认端口:`3001`。
+
+## 联调后端(sjnmtybt-server)
+
+1. 启动后端:`http://localhost:8088/sjnmtybt`
+2. `public/config/config.js` 设置 `useMock: false`(默认已关 mock)
+3. Vite 将 `/api` 代理到 `http://localhost:8088/sjnmtybt`
+
+详细页面流程与接口对照见:[docs/api-integration.md](./docs/api-integration.md)
+
+切回纯前端演示:把 `useMock` 改回 `true`。
+
 ## 脚本
 
 - `npm run dev` — 启动开发服务
@@ -29,14 +41,16 @@ npm run dev
 
 ```
 public/
-  config/config.js         # 全局变量配置(含系统名称,可部署后直接改)
+  config/config.js         # 全局变量配置(含 useMock / apiBase,可部署后直接改)
   data/                    # 静态数据
   js/                      # 静态脚本
+docs/
+  api-integration.md       # 与 sjnmtybt-server 联调说明
 src/
-  api/                     # 业务接口
+  api/                     # 业务接口(biz.ts + sjnmtybt 适配层)
   utils/                   # request / 防抖 / 节流 / appConfig
   layouts/                 # 布局
   router/                  # 路由
   styles/                  # Less 全局样式与变量
-  views/                   # 页面
+  views/                   # 页面(联调不改 UI)
 ```

+ 181 - 0
docs/api-integration.md

@@ -0,0 +1,181 @@
+# sj_tdtybz ↔ sjnmtybt-server 接口联调说明
+
+> 修订:2026-08-07  
+> 正式前端:`sj_tdtybz`(本仓库)  
+> 联调后端:`sjnmtybt-server`(Base:`http://localhost:8088/sjnmtybt`)  
+> 旧演示前端 `sjnmtybt-web` **不再作为业务页面入口**,仅可作接口对照参考。
+
+## 1. 目标与约束
+
+| 项 | 约定 |
+|----|------|
+| 页面流程 | 按 **sj_tdtybz 新版菜单/页面** 走(基础信息→审批→居保→月补贴→出盘/回盘→特殊业务→告警…) |
+| UI | **不改页面布局、不新增页面、不新增表单字段** |
+| 对接方式 | 仅在 API 层适配:`useMock=false` 时走真实接口,并把后端 VO **映射**为页面已有模型字段 |
+| Mock | `public/config/config.js` 中 `useMock: true` 仍可纯前端演示 |
+
+## 2. 如何切换联调
+
+1. 启动后端:`sjnmtybt-server`(端口 **8088**,context-path **`/sjnmtybt`**)
+2. 编辑 `public/config/config.js`:
+   - `useMock: false`
+   - `apiBase: '/api'`(默认)
+   - `currentMonth` 与后端发放月一致,如 `2026-08`
+3. 前端:`npm install && npm run dev`(默认 `http://localhost:3001`)
+4. Vite 代理:`/api/*` → `http://localhost:8088/sjnmtybt/api/*`
+
+本地鉴权默认关闭(`sjnmtybt.auth.enabled=false`)时可直接调业务接口。正式环境请求头带:
+
+```
+Token: <JWT>
+```
+
+(登录 OAuth 后把 JWT 写入 `localStorage.sjnmtybt_token` 或 `token`)
+
+## 3. 代码结构(只动接口层)
+
+```
+src/api/biz.ts                 # 页面调用入口;mock / real 分流
+src/api/sjnmtybt/realBiz.ts    # 真实接口编排(对齐页面函数签名)
+src/api/sjnmtybt/mappers.ts    # VO → 页面模型(镇村中文名、状态中文等)
+src/api/sjnmtybt/regionStore.ts# /api/regions 缓存与名↔ID
+src/utils/request.ts           # code=200、Token 头、FormData
+vite.config.ts                 # /api 代理到 8088/sjnmtybt
+```
+
+## 4. 页面流程 → 后端接口对照
+
+### 4.1 村合作社 · 基础信息 `/base`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 村批列表 | `fetchVillageBatches` | 先 `POST /api/batches/ensure-current-month` 再建本月草稿,再 `POST /api/batches/page`(VILLAGE);统一批次号展示发放年月(如 `202608`) |
+| 人员列表 | `fetchPeople` | `POST /api/personnel/page` |
+| 保存人员 | `savePerson` | `POST /api/personnel` + `ensure-current-month` |
+| 选人保存 | `updateBatchMembers` | `PUT /api/batches/{id}/members`(草稿/退回;暂停/停止/死亡默认不纳入) |
+| 本批次人员明细 | `fetchBatchMemberPeople` | `POST /api/payments/details/page`(**必须按 batchId**;勿用全村 `/personnel/page`,勿单靠 batchNo) |
+| 镇审批名单 | 同上 | 审批详情与批次详情同源:发放明细,不是人员库 |
+| 提交初审 | `submitVillageBatch` | `POST /api/batches/{id}/submit` |
+| 批次详情/流程 | `fetchBatchDetail` / `fetchBatchOverview` / `fetchBatchTimeline` | `GET /api/batches/{id}`、`GET /api/workflow/batches/{id}/overview`、`GET /api/process-logs/batches/{id}/timeline`;备注 `POST /api/process-logs/note` |
+
+### 4.2 镇经发 / 分管领导 · 审批中心 `/approvals`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 待办列表 | `fetchApprovals` | 批次 `PENDING_TOWN_APPROVE` / `PENDING_LEADER_APPROVE` + 特殊业务「待分管领导审批」拼成审批单 |
+| 资格核验 | `reviewPersonQualify` | `POST /api/personnel`(备注写入资格结论,不新增字段) |
+| 通过/驳回 | `decideApproval` | `POST /api/batches/town-approve` 或 `leader-approve`;特殊单 `POST /api/special-biz/leader-approve` |
+
+审批单 `id` 合成:`B:{batchId}` / `S:{specialId}`(仅适配层使用,页面仍显示原字段)。
+
+### 4.3 社区事务 · 居保匹配 `/insurance`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 待匹配村批 | `fetchInsuranceBatches` | 批次 status→阶段映射为「居保状态匹配/比对结果已生成」 |
+| 触发匹配 | `triggerInsuranceMatch` | `POST /api/insurance-match/trigger`;无 FAIL 时再 `generate` |
+| 比对结果 | `fetchInsuranceResults` | `GET /api/insurance-match/batches/{id}/results`(**必须传 batchId**;禁止回退全村人员库) |
+| 人工确认 | `confirmInsuranceManual` | `POST /api/insurance-match/manual-confirm` |
+| 提交领导 | `submitToLeader` | `POST /api/batches/{id}/submit-leader`(推进状态,非仅 generate) |
+
+### 4.4 月补贴清单 `/monthly`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 村级清单 | `fetchVillageMonthlyBatches` | `POST /api/batches/page` → 映射待提交/待审核/已审核/已出盘/已回盘 |
+| 出盘 | `issueVillageMonthlyDisk` | `POST /api/payments/export-disk`(`bypassDeadline` 便于联调) |
+| 导出 | `exportMonthlyFile` | 同上 |
+
+**状态映射(后端 `status` → 页面)**
+
+| 后端 BatchStatus | 页面 stage(流程) | 月补贴 status |
+|------------------|-------------------|---------------|
+| DRAFT | 人员新增录入 | — |
+| PENDING_TOWN_APPROVE | 镇经发中心初审 | — |
+| PENDING_INSURANCE_MATCH | 居保状态匹配 | — |
+| PENDING_STATUS_MAINTAIN | 比对结果已生成 | 待提交 |
+| PENDING_LEADER_APPROVE | 分管领导审批 | 待审核 |
+| READY_EXPORT | 待线下发放 | 已审核 |
+| EXPORTED | 回盘信息确认 | 已出盘 |
+| RETURNED / ARCHIVED | 已回盘确认 / 已归档 | 已回盘 |
+| REJECTED | 退回修改 | — |
+
+### 4.5 回盘 `/disk`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 回盘列表 | `fetchDisks` | 批次 EXPORTED/RETURNED/ARCHIVED |
+| 上传回盘 | `uploadReturnDiskFile` | `POST /api/return-disk/upload` + `confirm` |
+| 失败人员 | `fetchDiskFailPeople` | `POST /api/return-disk/details/page`(success=false) |
+| 归档 | `archiveToNextMonth` | `POST /api/dashboard/archive/{batchId}`(回盘页「归档」按钮) |
+
+### 4.6 特殊业务 `/special`
+
+| 页面动作 | 前端函数 | 后端接口 |
+|----------|----------|----------|
+| 列表 | `fetchSpecials` | `GET /api/special-biz/page` |
+| 发起 | `createSpecialBiz` | `POST /api/special-biz`(按姓名反查 personnelId) |
+| 调标触发 | `triggerAdjustReissue` | `POST /api/special-biz`(`STANDARD_ADJUST` + townId) |
+| 调标名单 | `fetchAdjustReissueRoster` | 调标村批 + 发放明细;原/新标准优先解析批次备注「旧月标/新月标」,否则查 `/api/amount-standards` |
+| 死亡录入 | `createDeathRecord` | 人员 FUNERAL / 状态更新 |
+| 状态动作 | `applyPersonStatusAction` | `POST /api/special-biz`(PAUSE/STOP/RESUME/FUNERAL/SUPPLEMENT) |
+
+### 4.7 告警 / 标准 / 工作台 / 报表
+
+| 页面 | 前端函数 | 后端接口 |
+|------|----------|----------|
+| 告警 | `fetchAlerts` / `handleAlert` | `POST /api/alerts/page`、`/handle`;人员信息回写 `POST /api/personnel` |
+| 补助标准 | `fetchStandards` / `createStandard` | `GET/POST /api/amount-standards`、`yearly-adjust` |
+| 工作台 | `fetchDashboard` | `GET /api/dashboard/summary` + `GET /api/dashboard/region-stats`(按镇/村填近半年图表与本月人数金额;镇级另查村批拼各村柱图) |
+| 报表批次 | `fetchReportBatches` | 同村批分页 |
+| 操作日志 | `fetchOpLogs` | `POST /api/process-logs/page` |
+| 账号管理 | `fetchAccounts` | **后端暂无账号接口**,返回空列表(不新增页面字段) |
+
+## 5. 字段映射(页面字段不增,仅转换)
+
+| 页面 Person | 后端 PersonnelVO |
+|-------------|------------------|
+| id | id(字符串业务 ID) |
+| idCard | idNumber |
+| town / village | townId / villageId → 中文名 |
+| bankCard | bankCardNumber |
+| standard | monthlyStandard |
+| startMonth | enjoyStartMonth(yyyyMM↔yyyy-MM) |
+| status | bizStatus 中文 |
+| insuranceStatus | insuranceMatchResult 中文 |
+| remark | remarks |
+
+镇村中文名与 ID 由 `regionStore` 维护(含叶榭镇等联调兜底名)。
+
+## 6. 角色与数据范围
+
+`config.js` 中角色用中文 `town` / `village`(如「叶榭镇」「叶榭村」),须与区划接口真实名称一致;适配层解析为 `T_YEXIE` / `V_*` 再请求后端。  
+切换角色:改 `currentRole`(village / town / social / leader / agri / hr / admin)。
+
+演示账号(121 环境)见后端文档 `sjnmtybt-server/docs/api-business-flow-guide.md` §0.0。
+
+## 7. 联调检查清单(闭环)
+
+1. **村合作社**:录入 → 选人(暂停/死亡默认不勾)→ 提交初审(提交时间有值)  
+2. **镇经发**:审批详情人数=发放明细 → 资格核验 → 通过(人员状态应变「待居保匹配」,不得仍为草稿)  
+3. **业务部门**:居保触发 → 成功落库「正常在库+匹配通过」;有 FAIL 须人工确认;**未匹配不可提交领导**  
+4. **分管领导**:审批通过 → 月补贴「已审核」  
+5. **业务部门**:出盘 → 回盘上传 → 失败人员处理 → **归档**  
+6. **系统管理员**:行政区划 / 人员底数 / 批次管理 / 系统管理菜单  
+7. **性能**:热路径 warm 宜 &lt;300ms(发放明细先分页再 enrichment;批次/人员/发放短缓存;看板 region-stats 45s;冷启动首次拉 DMS 可能略慢)
+
+## 8. 已知差异
+
+1. **审批中心**无独立审批表,由批次状态 + 特殊业务状态拼装。  
+2. **资格核验**结论写入人员 `remarks`(无独立枚举字段)。  
+3. **账号管理**无后端接口,系统管理页未挂菜单。  
+4. **比对清单 Excel 下载**未对接,页面提示在线查看结果。  
+5. **出盘**返回文件名/说明供线下签字,系统不托管资金支付文件流。
+
+## 9. 与旧前端关系
+
+| 项目 | 用途 |
+|------|------|
+| `sj_tdtybz` | **正式业务前端**(本联调对象) |
+| `sjnmtybt-web` | 历史联调页,**停用**;接口路径仍可对照 |
+| `sjnmtybt-server/docs/api-business-flow-guide.md` | 后端权威接口与状态机文档 |

Diferenças do arquivo suprimidas por serem muito extensas
+ 194 - 378
package-lock.json


+ 14 - 9
package.json

@@ -4,10 +4,15 @@
   "version": "0.0.0",
   "type": "module",
   "scripts": {
-    "dev": "vite",
+    "dev": "vite --mode online",
+    "dev:online": "vite --mode online",
+    "dev:local": "vite --mode local",
     "build": "vue-tsc -b && vite build",
     "preview": "vite preview"
   },
+  "engines": {
+    "node": ">=18"
+  },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.2",
     "axios": "^1.18.1",
@@ -18,14 +23,14 @@
     "vue-router": "^4.6.4"
   },
   "devDependencies": {
-    "@types/node": "^24.13.2",
-    "@vitejs/plugin-vue": "^6.0.8",
-    "@vue/tsconfig": "^0.9.1",
+    "@types/node": "^22.13.10",
+    "@vitejs/plugin-vue": "^5.2.3",
+    "@vue/tsconfig": "^0.7.0",
     "less": "^4.8.0",
-    "typescript": "~6.0.2",
-    "unplugin-auto-import": "^21.0.0",
-    "unplugin-vue-components": "^32.1.0",
-    "vite": "^8.1.5",
-    "vue-tsc": "^3.3.5"
+    "typescript": "~5.8.2",
+    "unplugin-auto-import": "^19.1.2",
+    "unplugin-vue-components": "^28.4.1",
+    "vite": "^5.4.18",
+    "vue-tsc": "^2.2.8"
   }
 }

+ 14 - 7
public/config/config.js

@@ -16,11 +16,13 @@ window.AppConfig = {
   systemSubtitle: '发放管理平台',
   // 请求超时(毫秒)
   requestTimeout: 15000,
-  // true:走本地 mock;false:走真实接口(对接时改此处)
-  useMock: true,
-  // 演示用当前角色(后续改为登录接口写入)
-  currentRole: 'village',
-  // 当前发放周期(演示)
+  // 业务 API 前缀(开发默认 /api,由 Vite 代理到 http://localhost:8088/sjnmtybt)
+  apiBase: '/api',
+  // true:走本地 mock;false:对接 sjnmtybt-server 真实接口
+  useMock: false,
+  // 演示用当前角色(后续改为登录接口写入;页面切换后会写入 localStorage,刷新不丢)
+  currentRole: 'admin',
+  // 当前发放周期(对齐后端 payMonth)
   currentMonth: '2026-08',
 
   /**
@@ -31,10 +33,11 @@ window.AppConfig = {
     {
       key: 'village',
       label: '村合作社',
-      org: '叶榭镇同建村合作社',
+      org: '叶榭镇叶榭村合作社',
       allData: false,
       town: '叶榭镇',
-      village: '同建村',
+      // 须与行政区划接口中真实村居一致(勿写接口不存在的村名)
+      village: '叶榭村',
     },
     {
       key: 'town',
@@ -93,6 +96,10 @@ window.AppConfig = {
       '/disk',
       '/alerts',
       '/reports',
+      '/regions',
+      '/personnel-admin',
+      '/admin-batches',
+      '/system',
     ],
   },
 

+ 24 - 0
scripts/fix-biz-braces.mjs

@@ -0,0 +1,24 @@
+import fs from 'fs'
+
+const path = new URL('../src/api/biz.ts', import.meta.url)
+let s = fs.readFileSync(path, 'utf8')
+
+// After "return real.xxx(...)" if next non-empty line is export/comment and no }, insert }
+s = s.replace(
+  /(return real\.\w+\([^;]*\))\r?\n(?=\r?\n?(?:\/\*|export ))/g,
+  '$1\n}\n',
+)
+
+// handleAlert may be missing final }
+if (!/return real\.fetchOpLogs\(\)\r?\n\}\r?\n?$/.test(s.trimEnd() + '\n')) {
+  s = s.replace(/(return real\.fetchOpLogs\(\))\s*$/, '$1\n}\n')
+}
+
+fs.writeFileSync(path, s)
+let bal = 0
+for (const c of s) {
+  if (c === '{') bal++
+  if (c === '}') bal--
+}
+console.log('brace balance', bal)
+console.log('tail:\n' + s.slice(-180))

+ 111 - 0
scripts/patch-biz-real.mjs

@@ -0,0 +1,111 @@
+import fs from 'fs'
+
+const path = new URL('../src/api/biz.ts', import.meta.url)
+let s = fs.readFileSync(path, 'utf8')
+
+if (!s.includes("from './sjnmtybt/realBiz'")) {
+  s = s.replace(
+    "import {\n  mockAccounts,",
+    "import * as real from './sjnmtybt/realBiz'\nimport {\n  mockAccounts,",
+  )
+}
+
+const pairs = [
+  ['fetchDashboard', 'params'],
+  ['fetchVillageBatches', ''],
+  ['fetchPeople', 'params'],
+  ['savePerson', 'payload'],
+  ['submitVillageBatch', 'batchId'],
+  ['fetchInsuranceBatches', ''],
+  ['fetchInsuranceResults', 'params'],
+  ['triggerInsuranceMatch', 'batchIds'],
+  ['confirmInsuranceManual', 'personId'],
+  ['submitToLeader', 'batchId'],
+  ['exportCompareList', '_batchId'],
+  ['returnBatchToTown', 'batchId, reason'],
+  ['fetchApprovals', 'params'],
+  ['reviewPersonQualify', 'payload'],
+  ['decideApproval', 'payload'],
+  ['fetchStatusBatches', ''],
+  ['fetchStandards', 'town'],
+  ['createStandard', 'payload'],
+  ['updateStandard', 'payload'],
+  ['updateDeathStatus', 'deathId'],
+  ['applyPersonStatusAction', 'payload'],
+  ['fetchSpecials', ''],
+  ['fetchDeaths', ''],
+  ['createDeathRecord', 'payload'],
+  ['createSpecialBiz', 'payload'],
+  ['exportSpecialSummary', 'type'],
+  ['triggerAdjustReissue', 'town'],
+  ['fetchAdjustReissueRoster', 'params'],
+  ['fetchVillageMonthlyBatches', 'town'],
+  ['fetchMonthlyBatches', ''],
+  ['calculateMonthly', ''],
+  ['confirmMonthly', 'id'],
+  ['exportMonthlyFile', 'id'],
+  ['issueVillageMonthlyDisk', 'batchId'],
+  ['confirmVillageMonthlyReturn', 'payload'],
+  ['fetchDisks', 'town'],
+  ['exportDisk', ''],
+  ['confirmReturnDisk', 'payload'],
+  ['uploadReturnDiskFile', 'payload'],
+  ['fetchDiskFailPeople', 'diskId'],
+  ['confirmDiskFailPaid', 'failPersonId'],
+  ['archiveToNextMonth', 'id'],
+  ['fetchAlerts', ''],
+  ['handleAlert', 'payload, result'],
+  ['fetchReportBatches', ''],
+  ['fetchAccounts', ''],
+  ['fetchOpLogs', ''],
+]
+
+const lines = s.split('\n')
+const out = []
+let i = 0
+let patched = 0
+
+while (i < lines.length) {
+  const line = lines[i]
+  const m = line.match(/^export function (\w+)/)
+  if (!m) {
+    out.push(line)
+    i++
+    continue
+  }
+  const fname = m[1]
+  const pair = pairs.find((p) => p[0] === fname)
+  let j = i
+  let brace = 0
+  let started = false
+  const block = []
+  while (j < lines.length) {
+    const L = lines[j]
+    block.push(L)
+    for (const ch of L) {
+      if (ch === '{') {
+        brace++
+        started = true
+      }
+      if (ch === '}') brace--
+    }
+    j++
+    if (started && brace === 0) break
+  }
+  if (!pair) {
+    out.push(...block)
+    i = j
+    continue
+  }
+  let body = block.join('\n')
+  const args = pair[1]
+  const call = args ? `return real.${fname}(${args})` : `return real.${fname}()`
+  // Replace the final real-api return (get/post) after mock branch
+  const next = body.replace(/\n  return (?:get|post)[\s\S]*$/m, `\n  ${call}`)
+  if (next !== body) patched++
+  out.push(...next.split('\n'))
+  i = j
+}
+
+fs.writeFileSync(path, out.join('\n'))
+console.log('patched functions:', patched)

+ 269 - 77
src/api/biz.ts

@@ -1,25 +1,13 @@
 import { getAppConfig } from '@/utils/appConfig'
-import { get, post } from '@/utils/request'
 import { DISK_RETURN_FAIL_REASONS } from '@/constants/diskReturn'
 import type {
-  AccountItem,
-  AlertItem,
   HandleAlertPayload,
-  Approval,
-  DashboardData,
-  DeathRecord,
-  DiskFailPerson,
-  DiskRecord,
-  MonthlyBatch,
-  OpLog,
   Person,
-  SpecialBiz,
-  SubsidyStandard,
   VillageBatch,
   VillageMonthlyBatch,
-  AdjustReissuePerson,
 } from '@/types/biz'
 import { buildDashboardByRole } from '@/utils/buildDashboard'
+import * as real from './sjnmtybt/realBiz'
 import {
   mockAccounts,
   mockAlerts,
@@ -59,13 +47,13 @@ export function fetchDashboard(params: {
       getAppConfig().roles[0]
     return delay(buildDashboardByRole(role, params.currentMonth))
   }
-  return get<DashboardData>('/dashboard', params)
+  return real.fetchDashboard(params)
 }
 
 /* ========== 基础信息 / 人员新增录入 ========== */
 export function fetchVillageBatches() {
   if (useMock()) return delay([...mockVillageBatches])
-  return get<VillageBatch[]>('/base/village-batches')
+  return real.fetchVillageBatches()
 }
 
 export function fetchPeople(params?: {
@@ -80,12 +68,12 @@ export function fetchPeople(params?: {
     if (params?.village) list = list.filter((p) => p.village === params.village)
     return delay(list)
   }
-  return get<Person[]>('/base/people', params)
+  return real.fetchPeople(params)
 }
 
 export function savePerson(payload: Partial<Person>) {
   if (useMock()) {
-    const id = Math.max(0, ...mockPeople.map((p) => p.id)) + 1
+    const id = Math.max(0, ...mockPeople.map((p) => Number(p.id) || 0)) + 1
     const month = getAppConfig().currentMonth
     mockPeople.push({
       id,
@@ -93,7 +81,7 @@ export function savePerson(payload: Partial<Person>) {
       name: payload.name || '',
       idCard: payload.idCard || '',
       town: payload.town || '叶榭镇',
-      village: payload.village || '同建村',
+      village: payload.village || '叶榭村',
       bankCard: payload.bankCard || '',
       bankName: payload.bankName || '',
       socialInsuranceCode: payload.socialInsuranceCode || '',
@@ -106,7 +94,59 @@ export function savePerson(payload: Partial<Person>) {
     })
     return delay({ ok: true })
   }
-  return post('/base/people', payload)
+  return real.savePerson(payload)
+}
+
+/** 更新批次纳入人员 */
+export function updateBatchMembers(batchId: string, personnelIds: string[]) {
+  if (useMock()) {
+    const batch = mockVillageBatches.find((b) => b.id === batchId)
+    if (batch) {
+      batch.peopleCount = personnelIds.length
+      batch.totalAmount = personnelIds.length * 1280
+    }
+    return delay({ ok: true })
+  }
+  return real.updateBatchMembers(batchId, personnelIds)
+}
+
+/** 批次已纳入人员 ID */
+export function fetchBatchMemberIds(batchId: string) {
+  if (useMock()) {
+    const batch = mockVillageBatches.find((b) => b.id === batchId)
+    if (!batch) return delay([] as string[])
+    const ids = mockPeople
+      .filter(
+        (p) =>
+          p.town === batch.town &&
+          p.village === batch.village &&
+          p.batchNo === batch.batchNo &&
+          !['暂停', '停止', '死亡丧葬', '已归档'].includes(p.status),
+      )
+      .map((p) => String(p.id))
+    return delay(ids)
+  }
+  return real.fetchBatchMemberIds(batchId)
+}
+
+/** 本批次人员明细(按发放明细,非全村人员库) */
+export function fetchBatchMemberPeople(
+  batchId: string,
+  opts?: { batchNo?: string; town?: string; village?: string; pruneDefaults?: boolean },
+) {
+  if (useMock()) {
+    const batch = mockVillageBatches.find((b) => b.id === batchId)
+    if (!batch) return delay([] as Person[])
+    const list = mockPeople.filter(
+      (p) =>
+        p.town === batch.town &&
+        p.village === batch.village &&
+        p.batchNo === batch.batchNo &&
+        !['暂停', '停止', '死亡丧葬', '已归档'].includes(p.status),
+    )
+    return delay(list)
+  }
+  return real.fetchBatchMemberPeople(batchId, opts)
 }
 
 /** 村合作社提交批次 → 镇经发中心初审 */
@@ -128,10 +168,117 @@ export function submitVillageBatch(batchId: string) {
         status: '待审批',
         createdAt: nowStr(),
       })
+      mockLogs.unshift({
+        id: Date.now(),
+        time: nowStr(),
+        user: '村合作社',
+        role: '村合作社',
+        module: '批次',
+        action: `提交初审 ${batch.batchNo}`,
+        result: '成功',
+      })
     }
     return delay({ ok: true })
   }
-  return post('/base/submit-batch', { batchId })
+  return real.submitVillageBatch(batchId)
+}
+
+/** 批次详情 */
+export function fetchBatchDetail(batchId: string) {
+  if (useMock()) {
+    const b = mockVillageBatches.find((x) => x.id === batchId)
+    return delay(b ? { ...b } : null)
+  }
+  return real.fetchBatchDetail(batchId)
+}
+
+/** 批次流程总览 */
+export function fetchBatchOverview(batchId: string) {
+  if (useMock()) {
+    const b = mockVillageBatches.find((x) => x.id === batchId)
+    return delay(
+      b
+        ? {
+            batchId: b.id,
+            batchNo: b.batchNo,
+            currentStage: b.stage,
+            status: b.stage,
+            stageDescription: `当前处于「${b.stage}」`,
+            availableActions:
+              b.stage === '人员新增录入' || b.stage === '退回修改'
+                ? ['SUBMIT_BATCH']
+                : [],
+            recentLogs: [
+              {
+                id: '1',
+                stage: b.stage,
+                action: '创建批次',
+                operator: b.submitter || '系统',
+                result: '成功',
+                operateTime: b.submittedAt || nowStr(),
+              },
+            ],
+          }
+        : null,
+    )
+  }
+  return real.fetchBatchOverview(batchId)
+}
+
+/** 批次流程时间线 */
+export function fetchBatchTimeline(batchId: string) {
+  if (useMock()) {
+    const b = mockVillageBatches.find((x) => x.id === batchId)
+    if (!b) return delay([])
+    return delay([
+      {
+        id: 'm1',
+        batchId: b.id,
+        batchNo: b.batchNo,
+        stage: '人员新增录入',
+        action: '创建本月村批',
+        operator: '系统',
+        result: '成功',
+        operateTime: nowStr(),
+      },
+      ...(b.submittedAt
+        ? [
+            {
+              id: 'm2',
+              batchId: b.id,
+              batchNo: b.batchNo,
+              stage: b.stage,
+              action: '提交/流转',
+              operator: b.submitter || '经办人',
+              result: '成功',
+              operateTime: b.submittedAt,
+            },
+          ]
+        : []),
+    ])
+  }
+  return real.fetchBatchTimeline(batchId)
+}
+
+/** 补充流程备注 */
+export function addBatchProcessNote(payload: {
+  batchId: string
+  opinion: string
+  fileUrls?: string
+}) {
+  if (useMock()) {
+    mockLogs.unshift({
+      id: Date.now(),
+      time: nowStr(),
+      user: '演示用户',
+      role: getAppConfig().currentRole,
+      module: '流程备注',
+      action: payload.opinion,
+      result: '成功',
+    })
+    return delay({ ok: true })
+  }
+  return real.addBatchProcessNote(payload)
 }
 
 /* ========== 居保匹配 ========== */
@@ -143,7 +290,7 @@ export function fetchInsuranceBatches() {
       ),
     )
   }
-  return get<VillageBatch[]>('/insurance/batches')
+  return real.fetchInsuranceBatches()
 }
 
 /** 查询居保匹配结果;可按批次号 / 街镇 / 村居过滤 */
@@ -159,7 +306,7 @@ export function fetchInsuranceResults(params?: {
     if (params?.village) list = list.filter((p) => p.village === params.village)
     return delay(list)
   }
-  return get<Person[]>('/insurance/results', params)
+  return real.fetchInsuranceResults(params)
 }
 
 /** 触发居保匹配:产出比对结果清单,批次进入「比对结果已生成」;支持单条或批量 */
@@ -174,12 +321,11 @@ export function triggerInsuranceMatch(batchIds?: string | string[]) {
     }
     return delay({ ok: true, count: targets.length })
   }
-  const ids = batchIds == null ? undefined : Array.isArray(batchIds) ? batchIds : [batchIds]
-  return post('/insurance/match', { batchIds: ids, batchId: ids?.[0] })
+  return real.triggerInsuranceMatch(batchIds)
 }
 
 /** 人工确认居保匹配结果 */
-export function confirmInsuranceManual(personId: number) {
+export function confirmInsuranceManual(personId: number | string) {
   if (useMock()) {
     const p = mockPeople.find((x) => x.id === personId)
     if (p) {
@@ -189,7 +335,7 @@ export function confirmInsuranceManual(personId: number) {
     }
     return delay({ ok: true })
   }
-  return post('/insurance/confirm', { personId })
+  return real.confirmInsuranceManual(personId)
 }
 
 /** 业务部门确认比对结果清单后提交分管领导审批 */
@@ -215,13 +361,13 @@ export function submitToLeader(batchId: string) {
     })
     return delay({ ok: true })
   }
-  return post('/insurance/submit-leader', { batchId })
+  return real.submitToLeader(batchId)
 }
 
 /** 导出比对结果清单(演示) */
 export function exportCompareList(_batchId?: string) {
   if (useMock()) return delay({ ok: true, fileName: `${getAppConfig().currentMonth.replace('-', '')}-比对结果清单.xlsx` })
-  return post('/insurance/export-compare', { batchId: _batchId })
+  return real.exportCompareList(_batchId)
 }
 
 /** 居保异常批次退回镇经发中心 */
@@ -246,7 +392,7 @@ export function returnBatchToTown(batchId: string, reason?: string) {
     }
     return delay({ ok: true })
   }
-  return post('/insurance/return-town', { batchId, reason })
+  return real.returnBatchToTown(batchId, reason)
 }
 
 /* ========== 审批 ========== */
@@ -271,7 +417,7 @@ export function fetchApprovals(params?: {
     }
     return delay(list)
   }
-  return get<Approval[]>('/approvals', params)
+  return real.fetchApprovals(params)
 }
 
 /**
@@ -279,7 +425,7 @@ export function fetchApprovals(params?: {
  * 将人员状态改为「资格已核验」,并记录有土地退养 / 无土地有资格
  */
 export function reviewPersonQualify(payload: {
-  personId: number
+  personId: number | string
   retireQualifyType: '有土地退养' | '无土地有资格'
 }) {
   if (useMock()) {
@@ -294,7 +440,7 @@ export function reviewPersonQualify(payload: {
     }
     return delay({ ok: true, person: { ...p } })
   }
-  return post('/approvals/review-qualify', payload)
+  return real.reviewPersonQualify(payload)
 }
 
 export function decideApproval(payload: {
@@ -345,7 +491,7 @@ export function decideApproval(payload: {
     }
     return delay({ ok: true })
   }
-  return post('/approvals/decide', payload)
+  return real.decideApproval(payload)
 }
 
 /* ========== 人员状态维护 ========== */
@@ -360,7 +506,7 @@ export function fetchStatusBatches() {
       ),
     )
   }
-  return get<VillageBatch[]>('/status/batches')
+  return real.fetchStatusBatches()
 }
 
 /** 补助标准列表;传 town 时仅返回该镇各年度标准 */
@@ -371,7 +517,7 @@ export function fetchStandards(town?: string) {
       [...list].sort((a, b) => (b.year !== a.year ? b.year - a.year : b.date.localeCompare(a.date))),
     )
   }
-  return get<SubsidyStandard[]>('/status/standards', town ? { town } : undefined)
+  return real.fetchStandards(town)
 }
 
 /** 新增年度补助标准 */
@@ -392,7 +538,7 @@ export function createStandard(payload: {
     mockStandards.unshift({ id, ...payload })
     return delay({ ok: true as const, id })
   }
-  return post<{ ok: boolean; message?: string; id?: number }>('/status/standards', payload)
+  return real.createStandard(payload)
 }
 
 /** 调整最新补助标准 */
@@ -412,7 +558,7 @@ export function updateStandard(payload: {
     row.reason = payload.reason
     return delay({ ok: true as const })
   }
-  return post<{ ok: boolean; message?: string }>('/status/standards/update', payload)
+  return real.updateStandard(payload)
 }
 
 /** 死亡状态更新(业务部门) */
@@ -431,11 +577,11 @@ export function updateDeathStatus(deathId: string) {
     }
     return delay({ ok: true })
   }
-  return post('/status/death-update', { deathId })
+  return real.updateDeathStatus(deathId)
 }
 
 export function applyPersonStatusAction(payload: {
-  personId: number
+  personId: number | string
   action: string
   /** 补发金额,可为负数 */
   amount?: number
@@ -459,18 +605,18 @@ export function applyPersonStatusAction(payload: {
     }
     return delay({ ok: true })
   }
-  return post('/status/action', payload)
+  return real.applyPersonStatusAction(payload)
 }
 
 /* ========== 特殊业务 ========== */
 export function fetchSpecials() {
   if (useMock()) return delay([...mockSpecials])
-  return get<SpecialBiz[]>('/special/list')
+  return real.fetchSpecials()
 }
 
 export function fetchDeaths() {
   if (useMock()) return delay([...mockDeaths])
-  return get<DeathRecord[]>('/special/deaths')
+  return real.fetchDeaths()
 }
 
 /** 镇社区事务中心:死亡人员信息录入 → 触发系统告警 */
@@ -537,7 +683,7 @@ export function createDeathRecord(payload: {
     })
     return delay({ ok: true, id })
   }
-  return post('/special/death', payload)
+  return real.createDeathRecord(payload)
 }
 
 /** 发起一般特殊业务 / 丧葬补贴 */
@@ -580,7 +726,7 @@ export function createSpecialBiz(payload: {
     })
     return delay({ ok: true, id })
   }
-  return post('/special/create', payload)
+  return real.createSpecialBiz(payload)
 }
 
 /** 特殊业务汇总文件导出(调标补发/丧葬/一般) */
@@ -589,7 +735,7 @@ export function exportSpecialSummary(type?: string) {
     const name = type ? `${getAppConfig().currentMonth.replace('-', '')}-${type}汇总文件.xlsx` : '特殊业务汇总文件.xlsx'
     return delay({ ok: true, fileName: name })
   }
-  return post('/special/export-summary', { type })
+  return real.exportSpecialSummary(type)
 }
 
 /**
@@ -725,13 +871,7 @@ export function triggerAdjustReissue(town?: string) {
       count: personCount,
     })
   }
-  return post<{
-    ok: boolean
-    batchNo?: string
-    villageCount?: number
-    count?: number
-    message?: string
-  }>('/special/trigger-adjust-reissue', { town })
+  return real.triggerAdjustReissue(town)
 }
 
 /** 查询调标补发名单(可按镇/批次/村过滤) */
@@ -747,7 +887,7 @@ export function fetchAdjustReissueRoster(params?: {
     if (params?.village) list = list.filter((r) => r.village === params.village)
     return delay(list)
   }
-  return get<AdjustReissuePerson[]>('/special/adjust-reissue-roster', params)
+  return real.fetchAdjustReissueRoster(params)
 }
 
 /* ========== 月补贴清单 ========== */
@@ -812,12 +952,12 @@ export function fetchVillageMonthlyBatches(town?: string) {
     })
     return delay(items)
   }
-  return get<VillageMonthlyBatch[]>('/monthly/village-batches', { town })
+  return real.fetchVillageMonthlyBatches(town)
 }
 
 export function fetchMonthlyBatches() {
   if (useMock()) return delay([...mockMonthlyBatches])
-  return get<MonthlyBatch[]>('/monthly/batches')
+  return real.fetchMonthlyBatches()
 }
 
 export function calculateMonthly() {
@@ -841,7 +981,7 @@ export function calculateMonthly() {
     }
     return delay({ ok: true })
   }
-  return post('/monthly/calculate')
+  return real.calculateMonthly()
 }
 
 export function confirmMonthly(id: string) {
@@ -870,7 +1010,7 @@ export function confirmMonthly(id: string) {
     }
     return delay({ ok: true })
   }
-  return post('/monthly/confirm', { id })
+  return real.confirmMonthly(id)
 }
 
 /** 导出月度拟发放文件(供线下签字与财务发放) */
@@ -885,7 +1025,7 @@ export function exportMonthlyFile(id?: string) {
       : '月补贴发放文件.xlsx'
     return delay({ ok: true, fileName: name })
   }
-  return post('/monthly/export', { id })
+  return real.exportMonthlyFile(id)
 }
 
 /**
@@ -922,7 +1062,7 @@ export function issueVillageMonthlyDisk(batchId: string) {
     }
     return delay({ ok: true, fileName })
   }
-  return post('/monthly/issue-disk', { batchId })
+  return real.issueVillageMonthlyDisk(batchId)
 }
 
 /**
@@ -968,7 +1108,7 @@ export function confirmVillageMonthlyReturn(payload: {
     }
     return delay({ ok: true })
   }
-  return post('/monthly/confirm-return', payload)
+  return real.confirmVillageMonthlyReturn(payload)
 }
 
 /* ========== 回盘信息确认 ========== */
@@ -978,7 +1118,7 @@ export function fetchDisks(town?: string) {
     const list = town ? mockDisks.filter((d) => d.town === town) : [...mockDisks]
     return delay([...list].sort((a, b) => b.batchNo.localeCompare(a.batchNo)))
   }
-  return get<DiskRecord[]>('/disk/list', town ? { town } : undefined)
+  return real.fetchDisks(town)
 }
 
 /** 导出回盘来源:月度拟发放文件(线上清单) */
@@ -991,7 +1131,7 @@ export function exportDisk() {
     }
     return delay({ ok: true })
   }
-  return post('/disk/export')
+  return real.exportDisk()
 }
 
 /** 回盘信息确认(登记线下发放结果,兼容旧调用) */
@@ -1060,7 +1200,7 @@ export function confirmReturnDisk(payload?: {
     }
     return delay({ ok: true })
   }
-  return post('/disk/confirm-return', payload)
+  return real.confirmReturnDisk(payload)
 }
 
 /**
@@ -1152,12 +1292,7 @@ export function uploadReturnDiskFile(payload: { id: string; file: File }) {
   const form = new FormData()
   form.append('id', payload.id)
   form.append('file', payload.file)
-  return post<{
-    ok: boolean
-    failCount?: number
-    alertCount?: number
-    message?: string
-  }>('/disk/upload-return', form as unknown as Record<string, unknown>)
+  return real.uploadReturnDiskFile(payload)
 }
 
 /** 查询某回盘批次的发放失败人员清单 */
@@ -1165,7 +1300,7 @@ export function fetchDiskFailPeople(diskId: string) {
   if (useMock()) {
     return delay(mockDiskFailPeople.filter((p) => p.diskId === diskId))
   }
-  return get<DiskFailPerson[]>('/disk/fail-people', { diskId })
+  return real.fetchDiskFailPeople(diskId)
 }
 
 /** 确认失败人员已完成发放 */
@@ -1179,7 +1314,7 @@ export function confirmDiskFailPaid(failPersonId: string) {
     row.payStatus = '已确认发放'
     return delay({ ok: true as const })
   }
-  return post<{ ok: boolean; message?: string }>('/disk/confirm-fail-paid', { id: failPersonId })
+  return real.confirmDiskFailPaid(failPersonId)
 }
 
 /** @deprecated 兼容旧调用,等同 confirmReturnDisk */
@@ -1207,13 +1342,13 @@ export function archiveToNextMonth(id?: string) {
     }
     return delay({ ok: true })
   }
-  return post('/disk/archive', { id })
+  return real.archiveToNextMonth(id)
 }
 
 /* ========== 告警 ========== */
 export function fetchAlerts() {
   if (useMock()) return delay([...mockAlerts])
-  return get<AlertItem[]>('/alerts')
+  return real.fetchAlerts()
 }
 
 export function handleAlert(payload: HandleAlertPayload | string, result?: string) {
@@ -1295,22 +1430,79 @@ export function handleAlert(payload: HandleAlertPayload | string, result?: strin
     }
     return delay({ ok: true })
   }
-  return post('/alerts/handle', typeof payload === 'string' ? { id: payload, result } : payload)
+  return real.handleAlert(payload, result)
 }
 
 /* ========== 查询统计 ========== */
 export function fetchReportBatches() {
   if (useMock()) return delay([...mockVillageBatches])
-  return get<VillageBatch[]>('/reports/batches')
+  return real.fetchReportBatches()
 }
 
 /* ========== 系统管理 ========== */
 export function fetchAccounts() {
   if (useMock()) return delay([...mockAccounts])
-  return get<AccountItem[]>('/system/accounts')
+  return real.fetchAccounts()
 }
 
 export function fetchOpLogs() {
   if (useMock()) return delay([...mockLogs])
-  return get<OpLog[]>('/system/logs')
+  return real.fetchOpLogs()
+}
+
+/** —— 系统管理员运维菜单 —— */
+export function listRegionsAdmin(params?: { level?: string; parentId?: string }) {
+  if (useMock()) return delay([])
+  return real.listRegionsAdmin(params)
+}
+
+export function saveRegionAdmin(payload: {
+  id?: string
+  code: string
+  name: string
+  level: string
+  parentId?: string
+  entrustPlanCode?: string
+  defaultBank?: string
+  enabled?: boolean
+  remarks?: string
+}) {
+  if (useMock()) return delay({ ok: true as const })
+  return real.saveRegionAdmin(payload)
+}
+
+export function deleteRegionAdmin(id: string) {
+  if (useMock()) return delay({ ok: true as const })
+  return real.deleteRegionAdmin(id)
+}
+
+export function pageAdminBatches(params?: {
+  payMonth?: string
+  townId?: string
+  villageId?: string
+  batchLevel?: string
+  status?: string
+}) {
+  if (useMock()) return delay([])
+  return real.pageAdminBatches(params)
+}
+
+export function purgeBatchAdmin(batchId: string) {
+  if (useMock()) return delay({ ok: true as const })
+  return real.purgeBatchAdmin(batchId)
+}
+
+export function deletePersonnelAdmin(personnelId: string) {
+  if (useMock()) return delay({ ok: true as const })
+  return real.deletePersonnelAdmin(personnelId)
+}
+
+export function updatePersonnelStatusAdmin(payload: {
+  personnelId: string
+  bizStatus: string
+  payThisMonth?: boolean
+  remarks?: string
+}) {
+  if (useMock()) return delay({ ok: true as const })
+  return real.updatePersonnelStatusAdmin(payload)
 }

+ 3 - 3
src/api/mock/data.ts

@@ -832,7 +832,7 @@ function socialInsuranceCodeOf(i: number) {
 
 /** 扩充人员:优先补足同建村近半年各批次,便于村合作社工作台柱状图演示 */
 ;(function expandPeople() {
-  const startId = Math.max(0, ...mockPeople.map((p) => p.id)) + 1
+  const startId = Math.max(0, ...mockPeople.map((p) => Number(p.id) || 0)) + 1
   const extra: Person[] = []
   /** 近半年批次:202603–202608 */
   const halfYearBatches = ['202603', '202604', '202605', '202606', '202607', '202608']
@@ -890,7 +890,7 @@ function socialInsuranceCodeOf(i: number) {
 ;(function expandTownBatchPeople() {
   const batchNo = '202608'
   const month = '2026-08'
-  let nextId = Math.max(0, ...mockPeople.map((p) => p.id)) + 1
+  let nextId = Math.max(0, ...mockPeople.map((p) => Number(p.id) || 0)) + 1
   const extra: Person[] = []
   for (const meta of TOWN_META) {
     meta.villages.forEach((village, vIdx) => {
@@ -949,7 +949,7 @@ function socialInsuranceCodeOf(i: number) {
 ;(function fillSocialInsuranceCodes() {
   mockPeople.forEach((p, idx) => {
     if (!p.socialInsuranceCode) {
-      p.socialInsuranceCode = socialInsuranceCodeOf(p.id || idx + 1)
+      p.socialInsuranceCode = socialInsuranceCodeOf(Number(p.id) || idx + 1)
     }
   })
 })()

+ 419 - 0
src/api/sjnmtybt/mappers.ts

@@ -0,0 +1,419 @@
+/**
+ * 后端 VO ↔ 新前端页面展示模型映射(不新增页面字段,只做值转换)
+ */
+import type {
+  AlertItem,
+  DiskFailPerson,
+  DiskRecord,
+  Person,
+  ProcessLogItem,
+  SpecialBiz,
+  SubsidyStandard,
+  VillageBatch,
+  VillageMonthlyBatch,
+} from '@/types/biz'
+import { regionName } from './regionStore'
+
+/** 后端批次 status → 前端 VillageBatch.stage(页面流程文案) */
+const BATCH_STATUS_TO_STAGE: Record<string, string> = {
+  DRAFT: '人员新增录入',
+  PENDING_TOWN_APPROVE: '镇经发中心初审',
+  PENDING_INSURANCE_MATCH: '居保状态匹配',
+  PENDING_STATUS_MAINTAIN: '比对结果已生成',
+  PENDING_LEADER_APPROVE: '分管领导审批',
+  READY_EXPORT: '待线下发放',
+  EXPORTED: '回盘信息确认',
+  RETURNED: '已回盘确认',
+  ARCHIVED: '已归档',
+  REJECTED: '退回修改',
+}
+
+/** 后端 status → 月补贴清单展示 status */
+const BATCH_STATUS_TO_MONTHLY: Record<string, string> = {
+  PENDING_STATUS_MAINTAIN: '待提交',
+  PENDING_LEADER_APPROVE: '待审核',
+  READY_EXPORT: '已审核',
+  EXPORTED: '已出盘',
+  RETURNED: '已回盘',
+  ARCHIVED: '已回盘',
+}
+
+const PERSON_STATUS_LABEL: Record<string, string> = {
+  DRAFT: '草稿',
+  PENDING_TOWN_APPROVE: '待镇审批',
+  PENDING_INSURANCE_MATCH: '待居保匹配',
+  NORMAL: '正常在库',
+  PAUSED: '暂停',
+  STOPPED: '停止',
+  FUNERAL: '死亡丧葬',
+  ABNORMAL: '异常待处理',
+  ARCHIVED: '已归档',
+}
+
+const INSURANCE_LABEL: Record<string, string> = {
+  PENDING: '未匹配',
+  SUCCESS: '匹配通过',
+  LOSE_ELIGIBILITY: '丧失资格',
+  FAIL: '匹配失败',
+  MANUAL_PASS: '匹配通过',
+}
+
+const ALERT_STATUS_LABEL: Record<string, string> = {
+  PENDING: '待处理',
+  PROCESSING: '处理中',
+  CLOSED: '已关闭',
+}
+
+const ALERT_LEVEL_LABEL: Record<string, string> = {
+  HIGH: '高',
+  MEDIUM: '中',
+  LOW: '低',
+}
+
+const SPECIAL_TYPE_LABEL: Record<string, string> = {
+  FUNERAL: '丧葬补贴',
+  PAUSE: '暂停发放',
+  STOP: '停止发放',
+  RESUME: '恢复发放',
+  STANDARD_ADJUST: '调标差额补发',
+  SUPPLEMENT: '纳入下月补发',
+  REFUND: '退款',
+  HEIR_UPDATE: '继承人信息变更',
+}
+
+const SPECIAL_TYPE_CODE: Record<string, string> = {
+  丧葬补贴: 'FUNERAL',
+  丧葬: 'FUNERAL',
+  暂停发放: 'PAUSE',
+  暂停: 'PAUSE',
+  停止发放: 'STOP',
+  停止: 'STOP',
+  恢复发放: 'RESUME',
+  恢复: 'RESUME',
+  调标差额补发: 'STANDARD_ADJUST',
+  调标补发: 'STANDARD_ADJUST',
+  纳入下月补发: 'SUPPLEMENT',
+  补发: 'SUPPLEMENT',
+}
+
+/** 发放年月形态:20 开头 6 位(含调标月键如 202699) */
+export function isPayMonthLike(value?: string): boolean {
+  const s = String(value || '').replace(/-/g, '')
+  return /^20\d{4}$/.test(s)
+}
+
+export function payMonthToMonth(payMonth?: string): string {
+  if (!payMonth) return ''
+  const s = String(payMonth).replace(/-/g, '')
+  if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4)}`
+  return payMonth
+}
+
+export function monthToPayMonth(month?: string): string {
+  if (!month) return ''
+  return String(month).replace(/-/g, '')
+}
+
+/**
+ * 统一批次号展示:优先发放年月 payMonth(如 202608)
+ * 历史库里可能把业务号前 6 位(如 214808)误填进 batchNo,需避开
+ */
+export function resolveUnifiedBatchNo(raw: Record<string, unknown>): string {
+  const pay = String(raw.payMonth || '').replace(/-/g, '')
+  const no = String(raw.batchNo || '').replace(/-/g, '')
+  if (isPayMonthLike(pay)) return pay
+  if (isPayMonthLike(no)) return no
+  // 都不是合法发放月:仍回传原值,便于排查脏数据
+  return String(raw.batchNo || raw.payMonth || raw.id || '')
+}
+
+export function fmtTime(ts?: number | string | null): string {
+  if (ts == null || ts === '') return ''
+  const n = Number(ts)
+  if (!Number.isFinite(n) || n <= 0) return String(ts)
+  const d = new Date(n)
+  if (Number.isNaN(d.getTime())) return String(ts)
+  const p = (x: number) => String(x).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
+}
+
+export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): Person {
+  const biz = String(raw.bizStatus || '')
+  const ins = String(raw.insuranceMatchResult || raw.insuranceStatus || '')
+  let status = PERSON_STATUS_LABEL[biz] || biz || '草稿'
+  // 居保失败待人工:页面用「待人工确认」
+  if (ins === 'FAIL' || status === '异常待处理') status = '待人工确认'
+  // 资格备注
+  const remarks = String(raw.remarks || '')
+  let retireQualifyType: Person['retireQualifyType']
+  if (remarks.includes('无土地')) retireQualifyType = '无土地有资格'
+  else if (remarks.includes('有土地') || remarks.includes('资格已核验')) retireQualifyType = '有土地退养'
+
+  return {
+    id: String(raw.id || '') as unknown as number,
+    batchNo: batchNoHint || '',
+    name: String(raw.name || ''),
+    idCard: String(raw.idNumber || ''),
+    town: regionName(String(raw.townId || '')),
+    village: regionName(String(raw.villageId || '')),
+    bankCard: String(raw.bankCardNumber || ''),
+    bankName: String(raw.bankName || ''),
+    socialInsuranceCode: raw.socialInsuranceCode ? String(raw.socialInsuranceCode) : undefined,
+    // 月补只认街镇标准表关联结果;无标准时勿回退 0/陈旧冗余值
+    standard:
+      raw.monthlyStandard == null || raw.monthlyStandard === ''
+        ? undefined
+        : Number(raw.monthlyStandard),
+    startMonth: payMonthToMonth(String(raw.enjoyStartMonth || '')),
+    status,
+    source: String(raw.source || ''),
+    insuranceStatus: INSURANCE_LABEL[ins] || String(raw.insuranceStatus || '未匹配'),
+    remark: remarks || undefined,
+    retireQualifyType,
+  }
+}
+
+const FLOW_STAGE_LABEL: Record<string, string> = {
+  STAGE1_PERSONNEL_INPUT: '人员新增录入',
+  STAGE2_TOWN_PRECHECK: '镇经发中心初审',
+  STAGE3_INSURANCE_MATCH: '居保状态匹配',
+  STAGE4_MONTHLY_LIST: '生成月补贴清单',
+  STAGE5_LEADER_ONLINE_APPROVE: '分管领导审批',
+  STAGE6_COMPARE_FILE: '生成对照文件',
+  STAGE7_OFFLINE_SIGN_PAY: '待线下发放',
+  STAGE8_RETURN_CONFIRM: '回盘信息确认',
+}
+
+export function flowStageLabel(code?: string): string {
+  if (!code) return ''
+  return FLOW_STAGE_LABEL[code] || BATCH_STATUS_TO_STAGE[code] || code
+}
+
+function resolveSubmittedAt(raw: Record<string, unknown>): string | undefined {
+  const fromField =
+    fmtTime(raw.submitTime as number | string) || fmtTime(raw.updateTime as number | string)
+  if (fromField) return fromField
+  const remarks = String(raw.remarks || '')
+  const m = remarks.match(/SUBMIT_TIME\|(\d+)/)
+  if (m?.[1]) return fmtTime(m[1])
+  return undefined
+}
+
+export function mapVillageBatch(raw: Record<string, unknown>): VillageBatch {
+  const status = String(raw.status || '')
+  const stage =
+    BATCH_STATUS_TO_STAGE[status] ||
+    flowStageLabel(String(raw.currentStage || '')) ||
+    status
+  const batchNo = resolveUnifiedBatchNo(raw)
+  const payMonth = isPayMonthLike(String(raw.payMonth || ''))
+    ? String(raw.payMonth).replace(/-/g, '')
+    : batchNo
+  return {
+    id: String(raw.id || ''),
+    batchNo,
+    month: payMonthToMonth(payMonth),
+    town: regionName(String(raw.townId || '')),
+    village: regionName(String(raw.villageId || '')),
+    peopleCount: Number(raw.peopleCount || 0),
+    totalAmount: Number(raw.totalAmount || 0),
+    stage,
+    submitter: String(raw.submitter || ''),
+    submittedAt: resolveSubmittedAt(raw),
+    statusCode: status || undefined,
+    exportStatus: raw.exportStatus ? String(raw.exportStatus) : undefined,
+    returnStatus: raw.returnStatus ? String(raw.returnStatus) : undefined,
+    exportDiskNo: raw.exportDiskNo ? String(raw.exportDiskNo) : undefined,
+    exportFileName: raw.exportFileName ? String(raw.exportFileName) : undefined,
+    townOpinion: raw.townOpinion ? String(raw.townOpinion) : undefined,
+    remarks: raw.remarks ? String(raw.remarks) : undefined,
+    normalAmount: Number(raw.normalAmount || 0) || undefined,
+    supplementAmount: Number(raw.supplementAmount || 0) || undefined,
+    deductAmount: Number(raw.deductAmount || 0) || undefined,
+  }
+}
+
+export function mapProcessLog(raw: Record<string, unknown>): ProcessLogItem {
+  return {
+    id: String(raw.id || ''),
+    batchId: raw.batchId ? String(raw.batchId) : undefined,
+    batchNo: raw.batchNo ? String(raw.batchNo) : undefined,
+    stage: flowStageLabel(String(raw.stage || '')),
+    action: String(raw.action || raw.approveNode || '办理'),
+    approveNode: raw.approveNode ? String(raw.approveNode) : undefined,
+    operator: String(raw.operator || raw.operatorRole || ''),
+    operatorRole: raw.operatorRole ? String(raw.operatorRole) : undefined,
+    operatorOrg: raw.operatorOrg ? String(raw.operatorOrg) : undefined,
+    opinion: raw.opinion ? String(raw.opinion) : undefined,
+    result: String(raw.result || raw.status || ''),
+    status: raw.status ? String(raw.status) : undefined,
+    operateTime: fmtTime(raw.operateTime as number),
+    fileUrls: raw.fileUrls ? String(raw.fileUrls) : undefined,
+  }
+}
+
+export function mapVillageMonthly(raw: Record<string, unknown>): VillageMonthlyBatch {
+  const b = mapVillageBatch(raw)
+  const status = String(raw.status || '')
+  return {
+    id: b.id,
+    batchNo: b.batchNo,
+    month: b.month,
+    town: b.town,
+    village: b.village,
+    peopleCount: b.peopleCount,
+    normalAmount: Number(raw.normalAmount || 0),
+    extraAmount: Number(raw.supplementAmount || 0),
+    deductAmount: Number(raw.deductAmount || 0),
+    totalAmount: Number(raw.totalAmount || b.totalAmount || 0),
+    status: BATCH_STATUS_TO_MONTHLY[status] || b.stage,
+    stage: b.stage,
+    submitter: b.submitter,
+    submittedAt: b.submittedAt,
+  }
+}
+
+export function mapDisk(raw: Record<string, unknown>): DiskRecord {
+  const status = String(raw.status || '')
+  const returnStatusRaw = String(raw.returnStatus || '未回盘')
+  let diskStatus = '待回盘'
+  let returnStatus = '待确认'
+  if (status === 'RETURNED' || status === 'ARCHIVED') {
+    diskStatus = '已回盘'
+    if (returnStatusRaw.includes('部分') || returnStatusRaw.includes('异常')) returnStatus = '部分失败'
+    else if (returnStatusRaw.includes('成功') || returnStatusRaw === '全部成功') returnStatus = '成功'
+    else returnStatus = '成功'
+  } else if (status === 'EXPORTED') {
+    diskStatus = '待回盘'
+    returnStatus = '待确认'
+  }
+  return {
+    id: String(raw.id || ''),
+    batchNo: String(raw.batchNo || ''),
+    town: regionName(String(raw.townId || '')),
+    fileName: String(raw.exportFileName || `${raw.batchNo || ''}-月补贴发放文件.xlsx`),
+    peopleCount: Number(raw.peopleCount || 0),
+    amount: Number(raw.totalAmount || 0),
+    status: diskStatus,
+    returnStatus,
+    failCount: 0,
+    returnFileName: undefined,
+  }
+}
+
+export function mapReturnFail(raw: Record<string, unknown>, diskId: string): DiskFailPerson {
+  return {
+    id: String(raw.id || raw.paymentDetailId || ''),
+    diskId,
+    batchNo: String(raw.batchId || ''),
+    town: regionName(String(raw.townId || '')),
+    village: regionName(String(raw.villageId || '')),
+    personName: String(raw.name || ''),
+    idCard: String(raw.idNumber || ''),
+    bankCard: undefined,
+    failReason: String(raw.failReasonDesc || raw.failReasonCode || ''),
+    amount: Number(raw.amount ?? raw.actualAmount ?? 0),
+    payStatus: raw.success === false ? '待确认' : '已确认发放',
+  }
+}
+
+function alertBatchLabel(raw: Record<string, unknown>): string {
+  const no = String(raw.batchNo || '').trim()
+  if (no) return no
+  const id = String(raw.batchId || '').trim()
+  if (id) return id
+  // 兼容历史:正文里「批次 VB…」
+  const content = String(raw.content || '')
+  const m = content.match(/批次\s*([A-Za-z0-9_-]+)/)
+  return m?.[1] || ''
+}
+
+export function mapAlert(raw: Record<string, unknown>): AlertItem {
+  const hs = String(raw.handleStatus || '')
+  const level = String(raw.level || '')
+  return {
+    id: String(raw.id || ''),
+    batchNo: alertBatchLabel(raw),
+    personName: String(raw.personnelName || ''),
+    town: regionName(String(raw.townId || '')),
+    village: regionName(String(raw.villageId || '')),
+    type: String(raw.type || ''),
+    level: ALERT_LEVEL_LABEL[level] || level,
+    content: String(raw.content || ''),
+    status: ALERT_STATUS_LABEL[hs] || hs || '待处理',
+    result: raw.handleResult ? String(raw.handleResult) : undefined,
+    createdAt: fmtTime(raw.createTime as number),
+    failReason: raw.failReasonCode ? String(raw.failReasonCode) : undefined,
+  }
+}
+
+export function mapSpecial(raw: Record<string, unknown>): SpecialBiz {
+  const bizType = String(raw.bizType || '')
+  return {
+    id: String(raw.id || ''),
+    batchNo: String(raw.batchNo || ''),
+    town: regionName(String(raw.townId || '')),
+    village: '',
+    type: SPECIAL_TYPE_LABEL[bizType] || bizType,
+    personName: String(raw.name || ''),
+    reason: String(raw.reason || ''),
+    amount: Number(raw.amount || 0),
+    status: String(raw.approveStatus || ''),
+    heirName: raw.heirName ? String(raw.heirName) : undefined,
+    createdAt: fmtTime(raw.createTime as number),
+  }
+}
+
+export function specialTypeToCode(type: string): string {
+  return SPECIAL_TYPE_CODE[type] || type
+}
+
+export function mapStandard(raw: Record<string, unknown>): SubsidyStandard {
+  // effectiveTime 可能是毫秒时间戳或 yyyy-MM-dd
+  let date = ''
+  const effRaw = raw.effectiveTime
+  if (typeof effRaw === 'number' && Number.isFinite(effRaw)) {
+    const d = new Date(effRaw)
+    date = Number.isNaN(d.getTime())
+      ? ''
+      : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+  } else {
+    const eff = String(effRaw || '')
+    date = eff.length >= 10 ? eff.slice(0, 10) : eff
+  }
+  const year = date ? Number(date.slice(0, 4)) : new Date().getFullYear()
+  // id 可能是字符串,页面用 number 比较「是否最新」——用 hash
+  const idStr = String(raw.id || '')
+  let idNum = Number(idStr)
+  if (!Number.isFinite(idNum)) {
+    idNum = [...idStr].reduce((s, c) => s + c.charCodeAt(0), 0)
+  }
+  return {
+    id: idNum,
+    town: String(raw.townName || regionName(String(raw.townId || ''))),
+    year: Number.isFinite(year) ? year : new Date().getFullYear(),
+    monthly: Number(raw.amount || 0),
+    funeral: Number(raw.funeralAmount || 0),
+    date,
+    reason: String(raw.adjustReason || raw.remarks || ''),
+  }
+}
+
+/** 审批单合成 ID:B:batchId / S:specialId */
+export function approvalIdBatch(batchId: string) {
+  return `B:${batchId}`
+}
+export function approvalIdSpecial(specialId: string) {
+  return `S:${specialId}`
+}
+export function parseApprovalId(id: string): { kind: 'batch' | 'special'; bizId: string } | null {
+  if (id.startsWith('B:')) return { kind: 'batch', bizId: id.slice(2) }
+  if (id.startsWith('S:')) return { kind: 'special', bizId: id.slice(2) }
+  // 兼容直接传批次/特殊单 ID
+  if (id.startsWith('VB') || id.startsWith('MB')) return { kind: 'batch', bizId: id }
+  return { kind: 'special', bizId: id }
+}
+
+export function personIdStr(id: number | string): string {
+  return String(id)
+}

+ 1681 - 0
src/api/sjnmtybt/realBiz.ts

@@ -0,0 +1,1681 @@
+/**
+ * sjnmtybt-server 真实接口实现(对齐新前端页面流程与既有函数签名)
+ * 页面仍调用 @/api/biz;useMock=false 时走到本模块。
+ */
+import { del, get, post, put } from '@/utils/request'
+import { getAppConfig } from '@/utils/appConfig'
+import { buildDashboardByRole } from '@/utils/buildDashboard'
+import type {
+  AccountItem,
+  AlertItem,
+  Approval,
+  DashboardData,
+  DeathRecord,
+  DiskFailPerson,
+  DiskRecord,
+  HandleAlertPayload,
+  MonthlyBatch,
+  OpLog,
+  Person,
+  SpecialBiz,
+  SubsidyStandard,
+  VillageBatch,
+  VillageMonthlyBatch,
+  AdjustReissuePerson,
+  BatchFlowOverview,
+  ProcessLogItem,
+} from '@/types/biz'
+import {
+  ensureTowns,
+  ensureVillages,
+  regionName,
+  resolveScopeIds,
+  townIdOf,
+  type RegionRow,
+} from './regionStore'
+import {
+  approvalIdBatch,
+  approvalIdSpecial,
+  flowStageLabel,
+  mapAlert,
+  mapDisk,
+  mapPerson,
+  mapProcessLog,
+  mapReturnFail,
+  mapSpecial,
+  mapStandard,
+  mapVillageBatch,
+  mapVillageMonthly,
+  monthToPayMonth,
+  parseApprovalId,
+  personIdStr,
+  specialTypeToCode,
+  isPayMonthLike,
+} from './mappers'
+
+type PageResult<T> = { list?: T[]; total?: number; pageNum?: number; pageSize?: number }
+
+async function pageAll<T>(
+  fetcher: (pageNum: number, pageSize: number) => Promise<PageResult<T> | null | undefined>,
+  pageSize = 100,
+  maxPages = 30,
+): Promise<T[]> {
+  const all: T[] = []
+  for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
+    const res = await fetcher(pageNum, pageSize)
+    const list = res?.list || []
+    all.push(...list)
+    if (list.length < pageSize || all.length >= Number(res?.total || 0)) break
+  }
+  return all
+}
+
+function currentPayMonth() {
+  return monthToPayMonth(getAppConfig().currentMonth)
+}
+
+function operatorOf() {
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  return role?.label || cfg.currentRole || '联调'
+}
+
+type MonthStatRow = {
+  payMonth?: string
+  peopleCount?: number
+  totalAmount?: number | string
+  actualAmount?: number | string
+  failCount?: number
+  batchCount?: number
+}
+
+type RegionDashRow = {
+  payMonth?: string
+  townId?: string
+  villageId?: string
+  grain?: string
+  personnelCount?: number
+  currentMonth?: MonthStatRow
+  series?: MonthStatRow[]
+}
+
+type BatchStatRow = {
+  townId?: string
+  villageId?: string
+  shouldPayCount?: number
+  shouldPayAmount?: number | string
+  actualPayAmount?: number | string
+}
+
+function formatPayMonthLabel(ym?: string) {
+  const s = String(ym || '')
+  if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4)}`
+  return s || '-'
+}
+
+function seriesToHalfYearCharts(series: MonthStatRow[] | undefined, months = 6) {
+  const list = Array.isArray(series) ? series.slice(-months) : []
+  return {
+    people: list.map((m) => ({
+      name: formatPayMonthLabel(m.payMonth),
+      value: Number(m.peopleCount || 0),
+    })),
+    amount: list.map((m) => ({
+      name: formatPayMonthLabel(m.payMonth),
+      value: Number(m.totalAmount || 0),
+    })),
+  }
+}
+
+/** 本批次各村发放人数/金额(镇级工作台) */
+async function buildTownVillageChartsFromApi(townId: string, payMonth: string) {
+  await ensureVillages(townId)
+  const batches = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/batches/page', {
+      pageNum,
+      pageSize,
+      townId,
+      payMonth,
+      batchLevel: 'VILLAGE',
+    }),
+  )
+  const peopleMap = new Map<string, number>()
+  const amountMap = new Map<string, number>()
+  for (const b of batches) {
+    if (String(b.status || '') === 'ARCHIVED') continue
+    const vName = regionName(String(b.villageId || '')) || String(b.villageId || '未知村')
+    peopleMap.set(vName, (peopleMap.get(vName) || 0) + Number(b.peopleCount || 0))
+    amountMap.set(vName, (amountMap.get(vName) || 0) + Number(b.totalAmount || 0))
+  }
+  const names = Array.from(new Set([...peopleMap.keys(), ...amountMap.keys()])).sort((a, b) =>
+    a.localeCompare(b, 'zh-CN'),
+  )
+  return {
+    people: names.map((name) => ({ name, value: peopleMap.get(name) || 0 })),
+    amount: names.map((name) => ({ name, value: amountMap.get(name) || 0 })),
+  }
+}
+
+/** —— 工作台 —— */
+export async function fetchDashboard(params: {
+  roleKey: string
+  currentMonth: string
+}): Promise<DashboardData> {
+  await ensureTowns()
+  const role =
+    getAppConfig().roles.find((r) => r.key === params.roleKey) || getAppConfig().roles[0]
+  const { townId, villageId } = await resolveScopeIds(role.town, role.village)
+  if (townId) await ensureVillages(townId)
+  const payMonth = monthToPayMonth(params.currentMonth)
+  const base = buildDashboardByRole(role, params.currentMonth)
+  base.batchInfo.batchNo = payMonth
+  base.batchInfo.scope =
+    role.village && role.town
+      ? `${role.town}/${role.village}`
+      : role.town
+        ? role.town
+        : role.allData
+          ? townId
+            ? regionName(townId) || role.town || '全区'
+            : '全区'
+          : base.batchInfo.scope
+
+  try {
+    const [summary, region] = await Promise.all([
+      get<Record<string, unknown>>('/dashboard/summary', {
+        payMonth,
+        townId: townId || undefined,
+      }).catch(() => null),
+      get<RegionDashRow>('/dashboard/region-stats', {
+        payMonth,
+        townId: townId || undefined,
+        villageId: villageId || undefined,
+        months: 6,
+      }).catch(() => null),
+    ])
+
+    const halfYear = seriesToHalfYearCharts(region?.series, 6)
+    const cur = region?.currentMonth
+    let regionPeople = Number(cur?.peopleCount ?? 0)
+    let regionAmount = Number(cur?.totalAmount ?? 0)
+    const pendingAlert = Number(summary?.pendingAlertCount || 0)
+
+    // 批次有人数但金额未回填时,用本月村/镇批次再算一遍(避免金额柱图全 0)
+    if ((regionPeople > 0 && regionAmount <= 0) || (villageId && regionPeople <= 0)) {
+      try {
+        const monthBatches = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+          post<PageResult<Record<string, unknown>>>('/batches/page', {
+            pageNum,
+            pageSize,
+            payMonth,
+            townId: townId || undefined,
+            villageId: villageId || undefined,
+            batchLevel: villageId ? 'VILLAGE' : undefined,
+          }),
+        )
+        let p = 0
+        let a = 0
+        for (const b of monthBatches) {
+          if (String(b.status || '') === 'ARCHIVED') continue
+          p += Number(b.peopleCount || 0)
+          a += Number(b.totalAmount || 0)
+        }
+        if (p > 0) regionPeople = p
+        if (a > 0) regionAmount = a
+      } catch {
+        /* ignore */
+      }
+    }
+    if (regionPeople <= 0) regionPeople = Number(summary?.totalPeople || 0)
+    if (regionAmount <= 0) regionAmount = Number(summary?.totalAmount || 0)
+
+    // 把本月校正写回近半年序列最后一柱
+    if (halfYear.people.length) {
+      const lastIdx = halfYear.people.length - 1
+      if (regionPeople > 0) halfYear.people[lastIdx].value = regionPeople
+      if (regionAmount > 0) halfYear.amount[lastIdx].value = regionAmount
+    }
+
+    // 汇总卡片:优先区划统计(含村级),保证切换镇/村后数字一致
+    base.stats.amount = regionAmount
+    base.stats.alert = pendingAlert
+    base.stats.normal = Number(region?.personnelCount ?? regionPeople)
+    base.batchInfo.peopleCount = regionPeople
+    base.batchInfo.amount = regionAmount
+    base.batchInfo.alertCount = pendingAlert
+
+    // 村合作社:近半年图表用真实序列(不再用 mock 骨架)
+    if (role.village || role.key === 'village') {
+      base.villageHalfYearPeopleChart = halfYear.people
+      base.villageHalfYearAmountChart = halfYear.amount
+      base.townAmountChart = halfYear.amount
+      base.batchCompareChart = halfYear.amount.map((i, idx) => ({
+        ...i,
+        count: halfYear.people[idx]?.value,
+      }))
+    }
+
+    // 镇级:本批次各村柱状图
+    if (townId && !villageId && (role.key === 'town' || role.key === 'social' || role.key === 'leader')) {
+      try {
+        const villageCharts = await buildTownVillageChartsFromApi(townId, payMonth)
+        base.townVillagePeopleChart = villageCharts.people
+        base.townVillageAmountChart = villageCharts.amount
+        base.townAmountChart = villageCharts.amount
+      } catch {
+        /* 保留骨架 */
+      }
+      // 镇级也可看近半年趋势(右侧/其他图用到时)
+      if (halfYear.people.length) {
+        base.batchCompareChart = halfYear.amount.map((i, idx) => ({
+          ...i,
+          count: halfYear.people[idx]?.value,
+        }))
+      }
+    }
+
+    // 区级:近半年 + 各街镇(来自 summary.townStats)
+    if (role.allData && base.districtOverview) {
+      base.districtOverview.halfYearPeopleChart = halfYear.people
+      base.districtOverview.halfYearAmountChart = halfYear.amount
+      base.districtOverview.peopleCount = regionPeople
+      base.districtOverview.amount = regionAmount
+      const townStats = (summary?.townStats as BatchStatRow[] | undefined) || []
+      if (townStats.length) {
+        const byTown = new Map<string, { people: number; amount: number }>()
+        for (const s of townStats) {
+          const name = regionName(String(s.townId || '')) || String(s.townId || '未知')
+          const curT = byTown.get(name) || { people: 0, amount: 0 }
+          curT.people += Number(s.shouldPayCount || 0)
+          curT.amount += Number(s.shouldPayAmount || 0)
+          byTown.set(name, curT)
+        }
+        const names = Array.from(byTown.keys()).sort((a, b) => a.localeCompare(b, 'zh-CN'))
+        base.districtOverview.townCount = names.length
+        base.districtOverview.townPeopleChart = names.map((n) => ({
+          name: n,
+          value: byTown.get(n)?.people || 0,
+        }))
+        base.districtOverview.townAmountChart = names.map((n) => ({
+          name: n,
+          value: byTown.get(n)?.amount || 0,
+        }))
+        base.districtOverview.townSummary = names.map((n) => ({
+          town: n,
+          villageCount: 0,
+          peopleCount: byTown.get(n)?.people || 0,
+          amount: byTown.get(n)?.amount || 0,
+          withLandCount: 0,
+          withoutLandCount: 0,
+          qualifiedCount: byTown.get(n)?.people || 0,
+        }))
+        base.townAmountChart = base.districtOverview.townAmountChart
+      }
+    }
+
+    return base
+  } catch {
+    return base
+  }
+}
+
+/** —— 人员 / 村批 —— */
+export async function fetchVillageBatches(): Promise<VillageBatch[]> {
+  await ensureTowns()
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const { townId, villageId } = await resolveScopeIds(role?.town, role?.village)
+  if (townId) await ensureVillages(townId)
+  const payMonth = currentPayMonth()
+
+  // 进「人员新增录入」即确保本村本月草稿批(对齐后端流程:ensure-current-month)
+  if (townId && villageId) {
+    await post('/batches/ensure-current-month', undefined, {
+      params: { townId, villageId, payMonth },
+    }).catch(() => undefined)
+  }
+
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/batches/page', {
+      pageNum,
+      pageSize,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      batchLevel: 'VILLAGE',
+    }),
+  )
+  const mapped = list.map(mapVillageBatch)
+  // 合法发放月优先靠前;脏业务号(如 214808)沉底
+  return mapped.sort((a, b) => {
+    const ak = isPayMonthLike(a.batchNo) ? a.batchNo : `0${a.batchNo}`
+    const bk = isPayMonthLike(b.batchNo) ? b.batchNo : `0${b.batchNo}`
+    if (ak !== bk) return bk.localeCompare(ak)
+    return String(b.id).localeCompare(String(a.id))
+  })
+}
+
+export async function fetchPeople(params?: {
+  batchNo?: string
+  town?: string
+  village?: string
+}): Promise<Person[]> {
+  await ensureTowns()
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const town = params?.town || role?.town
+  const village = params?.village || role?.village
+  const { townId, villageId } = await resolveScopeIds(town, village)
+  if (townId) await ensureVillages(townId)
+
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/personnel/page', {
+      pageNum,
+      pageSize,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      payMonth: params?.batchNo || undefined,
+    }),
+  )
+  return list.map((p) => mapPerson(p, params?.batchNo || currentPayMonth()))
+}
+
+export async function savePerson(payload: Partial<Person>) {
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const town = payload.town || role?.town
+  const village = payload.village || role?.village
+  const { townId, villageId } = await resolveScopeIds(town, village)
+  if (!townId || !villageId) throw new Error('请先选择街镇与村居')
+  await post('/personnel', {
+    name: payload.name,
+    idNumber: payload.idCard,
+    townId,
+    villageId,
+    bankCardNumber: payload.bankCard,
+    bankName: payload.bankName,
+    socialInsuranceCode: payload.socialInsuranceCode,
+    monthlyStandard: payload.standard,
+    enjoyStartMonth: monthToPayMonth(payload.startMonth) || currentPayMonth(),
+    source: '村级录入',
+    remarks: payload.remark,
+  })
+  // 确保本月村批存在
+  await post('/batches/ensure-current-month', undefined, {
+    params: { townId, villageId, payMonth: currentPayMonth() },
+  }).catch(() => undefined)
+  return { ok: true }
+}
+
+export async function submitVillageBatch(batchId: string) {
+  await post(`/batches/${encodeURIComponent(batchId)}/submit`)
+  return { ok: true }
+}
+
+/** 更新批次纳入人员(草稿/退回可改) */
+export async function updateBatchMembers(batchId: string, personnelIds: string[]) {
+  await put(
+    `/batches/${encodeURIComponent(batchId)}/members`,
+    {
+      personnelIds,
+      operator: operatorOf(),
+    },
+    { timeout: 60000 },
+  )
+  return { ok: true }
+}
+
+/** 批次已纳入人员 ID(发放明细) */
+export async function fetchBatchMemberIds(batchId: string): Promise<string[]> {
+  if (!batchId) return []
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/payments/details/page', {
+      pageNum,
+      pageSize,
+      batchId,
+    }),
+  )
+  return list.map((d) => String(d.personnelId || '')).filter(Boolean)
+}
+
+/**
+ * 本批次人员明细:只返回发放明细中的人(不是全村人员库)。
+ * 草稿/退回批会先走 ensure-current-month,剔除暂停/停止/死亡等默认不应在册人员。
+ */
+export async function fetchBatchMemberPeople(
+  batchId: string,
+  opts?: { batchNo?: string; town?: string; village?: string; pruneDefaults?: boolean },
+): Promise<Person[]> {
+  if (!batchId) return []
+  await ensureTowns()
+  const town = opts?.town
+  const village = opts?.village
+  if (opts?.pruneDefaults !== false && town && village) {
+    const { townId, villageId } = await resolveScopeIds(town, village)
+    if (townId && villageId) {
+      await post('/batches/ensure-current-month', undefined, {
+        params: {
+          townId,
+          villageId,
+          payMonth: opts?.batchNo || currentPayMonth(),
+        },
+      }).catch(() => undefined)
+    }
+  }
+
+  const details = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/payments/details/page', {
+      pageNum,
+      pageSize,
+      batchId,
+    }),
+  )
+  if (!details.length) return []
+
+  const roster = await fetchPeople({ town, village }).catch(() => [] as Person[])
+  const byId = new Map(roster.map((p) => [String(p.id), p]))
+  const batchNo = opts?.batchNo || currentPayMonth()
+
+  const result: Person[] = []
+  for (const d of details) {
+    const pid = String(d.personnelId || '')
+    if (!pid) continue
+    const base = byId.get(pid)
+    if (base) {
+      result.push({
+        ...base,
+        batchNo,
+        standard: Number(d.monthlyAmount ?? base.standard ?? 0),
+        bankCard: String(d.bankCardNumber || base.bankCard || ''),
+        remark: String(d.remarks || base.remark || ''),
+      })
+      continue
+    }
+    result.push(
+      mapPerson(
+        {
+          id: pid,
+          name: d.name,
+          idNumber: d.idNumber,
+          townId: d.townId,
+          villageId: d.villageId,
+          bankCardNumber: d.bankCardNumber,
+          monthlyStandard: d.monthlyAmount,
+          remarks: d.remarks,
+          bizStatus: 'NORMAL',
+        },
+        batchNo,
+      ),
+    )
+  }
+  return result
+}
+
+/** 批次详情(原始 BatchVO → VillageBatch) */
+export async function fetchBatchDetail(batchId: string): Promise<VillageBatch | null> {
+  if (!batchId) return null
+  await ensureTowns()
+  const raw = await get<Record<string, unknown>>(`/batches/${encodeURIComponent(batchId)}`)
+  if (!raw) return null
+  if (raw.townId) await ensureVillages(String(raw.townId))
+  return mapVillageBatch(raw)
+}
+
+/** 批次流程总览(可执行动作 + 阶段说明) */
+export async function fetchBatchOverview(batchId: string): Promise<BatchFlowOverview | null> {
+  if (!batchId) return null
+  try {
+    const raw = await get<Record<string, unknown>>(
+      `/workflow/batches/${encodeURIComponent(batchId)}/overview`,
+    )
+    if (!raw) return null
+    const logs = Array.isArray(raw.recentLogs)
+      ? (raw.recentLogs as Record<string, unknown>[]).map(mapProcessLog)
+      : []
+    return {
+      batchId: String(raw.batchId || batchId),
+      batchNo: String(raw.batchNo || ''),
+      currentStage: flowStageLabel(String(raw.currentStage || '')),
+      status: String(raw.status || ''),
+      stageDescription: raw.stageDescription ? String(raw.stageDescription) : undefined,
+      availableActions: Array.isArray(raw.availableActions)
+        ? (raw.availableActions as string[])
+        : [],
+      recentLogs: logs,
+    }
+  } catch {
+    return null
+  }
+}
+
+/** 批次流程时间线 */
+export async function fetchBatchTimeline(batchId: string): Promise<ProcessLogItem[]> {
+  if (!batchId) return []
+  try {
+    const res = await get<PageResult<Record<string, unknown>> | Record<string, unknown>[]>(
+      `/process-logs/batches/${encodeURIComponent(batchId)}/timeline`,
+    )
+    const list = Array.isArray(res) ? res : res?.list || []
+    return list
+      .map(mapProcessLog)
+      .sort((a, b) => String(b.operateTime || '').localeCompare(String(a.operateTime || '')))
+  } catch {
+    return []
+  }
+}
+
+/** 补充流程备注 */
+export async function addBatchProcessNote(payload: {
+  batchId: string
+  opinion: string
+  fileUrls?: string
+}) {
+  await post('/process-logs/note', {
+    batchId: payload.batchId,
+    opinion: payload.opinion,
+    fileUrls: payload.fileUrls,
+    operator: operatorOf(),
+  })
+  return { ok: true }
+}
+
+/** —— 居保 —— */
+/** 人工确认需要 batchId:记录最近一次打开的居保批次 */
+let lastInsuranceBatchId = ''
+
+export async function fetchInsuranceBatches(): Promise<VillageBatch[]> {
+  const all = await fetchVillageBatches()
+  return all.filter((b) => b.stage === '居保状态匹配' || b.stage === '比对结果已生成')
+}
+
+export async function fetchInsuranceResults(params?: {
+  batchId?: string
+  batchNo?: string
+  town?: string
+  village?: string
+}): Promise<Person[]> {
+  let batchId = params?.batchId || lastInsuranceBatchId
+  if (!batchId) {
+    const batches = await fetchVillageBatches()
+    const hit = batches.find(
+      (b) =>
+        (!params?.batchNo || b.batchNo === params.batchNo) &&
+        (!params?.town || b.town === params.town) &&
+        (!params?.village || b.village === params.village) &&
+        (b.stage === '居保状态匹配' ||
+          b.stage === '比对结果已生成' ||
+          b.stage === '分管领导审批'),
+    )
+    batchId = hit?.id || ''
+  }
+  if (!batchId) return []
+  lastInsuranceBatchId = batchId
+  try {
+    const rows = await get<Record<string, unknown>[]>(
+      `/insurance-match/batches/${encodeURIComponent(batchId)}/results`,
+    )
+    if (!Array.isArray(rows) || !rows.length) return []
+    return rows.map((r) =>
+      mapPerson(
+        {
+          id: r.personnelId || r.id,
+          name: r.name || r.personnelName,
+          idNumber: r.idNumber,
+          townId: r.townId,
+          villageId: r.villageId,
+          insuranceMatchResult: r.matchResult || r.insuranceMatchResult || r.result,
+          bizStatus: r.bizStatus || 'PENDING_INSURANCE_MATCH',
+          remarks: r.remarks || r.failReason || r.message,
+        },
+        params?.batchNo,
+      ),
+    )
+  } catch {
+    return []
+  }
+}
+
+export async function triggerInsuranceMatch(batchIds?: string | string[]) {
+  const ids = batchIds == null ? [] : Array.isArray(batchIds) ? batchIds : [batchIds]
+  let count = 0
+  for (const batchId of ids) {
+    lastInsuranceBatchId = batchId
+    const res = await post<{ nextStatus?: string; failCount?: number }>(
+      '/insurance-match/trigger',
+      { batchId, operator: operatorOf() },
+    )
+    // 全员成功时后端已进领导审;仍补齐明细。有 FAIL 时不要假装已生成可提交清单
+    if (!res?.failCount) {
+      await post(`/payments/batches/${encodeURIComponent(batchId)}/generate`).catch(() => undefined)
+    }
+    count += 1
+  }
+  return { ok: true, count }
+}
+
+export async function confirmInsuranceManual(personId: number | string, batchId?: string) {
+  const bid = batchId || lastInsuranceBatchId
+  if (!bid) {
+    throw new Error('请先打开村居比对结果后再人工确认')
+  }
+  lastInsuranceBatchId = bid
+  await post('/insurance-match/manual-confirm', {
+    personnelId: personIdStr(personId),
+    batchId: bid,
+    pass: true,
+    operator: operatorOf(),
+  })
+  return { ok: true }
+}
+
+/** 居保完成后提交分管领导(推进批次状态,非仅生成明细) */
+export async function submitToLeader(batchId: string) {
+  await post(`/batches/${encodeURIComponent(batchId)}/submit-leader`)
+  return { ok: true }
+}
+
+export async function exportCompareList(_batchId?: string) {
+  return {
+    ok: false as const,
+    message: '比对清单文件导出接口尚未对接,请在「查看比对结果」中核对名单',
+    fileName: '',
+  }
+}
+
+export async function returnBatchToTown(batchId: string, reason?: string) {
+  await post('/batches/town-approve', {
+    bizId: batchId,
+    action: 'REJECT',
+    opinion: reason || '居保匹配异常,退回镇经发中心',
+    operator: operatorOf(),
+  })
+  return { ok: true }
+}
+
+/** —— 审批 —— */
+export async function fetchApprovals(params?: {
+  tab?: string
+  node?: string
+  town?: string
+  batchNo?: string
+  bizTypeIncludes?: string
+}): Promise<Approval[]> {
+  await ensureTowns()
+  const { townId } = await resolveScopeIds(params?.town, undefined)
+  const list: Approval[] = []
+
+  // 镇经发:待初审村批
+  if (!params?.node || params.node.includes('镇经发')) {
+    const townPending = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+      post<PageResult<Record<string, unknown>>>('/batches/page', {
+        pageNum,
+        pageSize,
+        townId: townId || undefined,
+        batchLevel: 'VILLAGE',
+        status: 'PENDING_TOWN_APPROVE',
+        batchNo: params?.batchNo,
+      }),
+    )
+    for (const b of townPending) {
+      const vb = mapVillageBatch(b)
+      list.push({
+        id: approvalIdBatch(vb.id),
+        batchId: vb.id,
+        bizType: '月度批次人员申请',
+        batchNo: vb.batchNo,
+        town: vb.town,
+        village: vb.village,
+        title: `${vb.village}${vb.month}批次人员申请`,
+        applicant: `${vb.village}合作社`,
+        node: '镇经发中心初审',
+        status: '待审批',
+        createdAt: vb.submittedAt || '',
+        peopleCount: vb.peopleCount,
+      })
+    }
+  }
+
+  // 分管领导:待审月度清单
+  if (!params?.node || params.node.includes('分管领导')) {
+    const leaderPending = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+      post<PageResult<Record<string, unknown>>>('/batches/page', {
+        pageNum,
+        pageSize,
+        townId: townId || undefined,
+        batchLevel: 'VILLAGE',
+        status: 'PENDING_LEADER_APPROVE',
+        batchNo: params?.batchNo,
+      }),
+    )
+    for (const b of leaderPending) {
+      const vb = mapVillageBatch(b)
+      list.push({
+        id: approvalIdBatch(vb.id),
+        batchId: vb.id,
+        bizType: '月补贴清单确认',
+        batchNo: vb.batchNo,
+        town: vb.town,
+        village: vb.village,
+        title: `${vb.town}${vb.village}月补贴清单提交分管领导`,
+        applicant: `${vb.town}社区事务受理服务中心`,
+        node: '分管领导线上审批',
+        status: '待审批',
+        createdAt: vb.submittedAt || '',
+        peopleCount: vb.peopleCount,
+      })
+    }
+
+    // 特殊业务待审
+    const specials = await get<PageResult<Record<string, unknown>>>('/special-biz/page', {
+      pageNum: 1,
+      pageSize: 200,
+      approveStatus: '待分管领导审批',
+    })
+    for (const s of specials?.list || []) {
+      const sp = mapSpecial(s)
+      if (params?.town && sp.town && sp.town !== params.town) continue
+      list.push({
+        id: approvalIdSpecial(sp.id),
+        bizType: sp.type,
+        batchNo: sp.batchNo,
+        town: sp.town,
+        village: sp.village,
+        title: `${sp.personName || sp.town}${sp.type}申请`,
+        applicant: `${sp.town}社区事务受理服务中心`,
+        node: '分管领导线上审批',
+        status: '待审批',
+        createdAt: sp.createdAt,
+      })
+    }
+  }
+
+  let out = list
+  if (params?.tab === 'pending') out = out.filter((a) => a.status === '待审批')
+  if (params?.bizTypeIncludes) {
+    out = out.filter((a) => a.bizType.includes(params.bizTypeIncludes!))
+  }
+  return out
+}
+
+export async function reviewPersonQualify(payload: {
+  personId: number | string
+  retireQualifyType: '有土地退养' | '无土地有资格'
+}) {
+  const id = personIdStr(payload.personId)
+  const detail = await get<Record<string, unknown>>(`/personnel/${encodeURIComponent(id)}`)
+  const tip =
+    payload.retireQualifyType === '无土地有资格'
+      ? '无承包地,按政策认定具备退养补助资格;资格已核验'
+      : '有土地退养;资格已核验'
+  await post('/personnel', {
+    id,
+    name: detail?.name,
+    idNumber: detail?.idNumber,
+    townId: detail?.townId,
+    villageId: detail?.villageId,
+    bankCardNumber: detail?.bankCardNumber,
+    bankName: detail?.bankName,
+    socialInsuranceCode: detail?.socialInsuranceCode,
+    monthlyStandard: detail?.monthlyStandard,
+    enjoyStartMonth: detail?.enjoyStartMonth,
+    source: detail?.source,
+    remarks: [detail?.remarks, tip].filter(Boolean).join(';'),
+  })
+  return { ok: true }
+}
+
+export async function decideApproval(payload: {
+  id: string
+  pass: boolean
+  opinion?: string
+}) {
+  const parsed = parseApprovalId(payload.id)
+  if (!parsed) return { ok: false }
+  const body = {
+    bizId: parsed.bizId,
+    action: payload.pass ? 'PASS' : 'REJECT',
+    opinion: payload.opinion || (payload.pass ? '同意' : '退回修改'),
+    operator: operatorOf(),
+  }
+  if (parsed.kind === 'batch') {
+    // 按批次当前状态选接口
+    const batch = await get<Record<string, unknown>>(`/batches/${encodeURIComponent(parsed.bizId)}`)
+    const st = String(batch?.status || '')
+    if (st === 'PENDING_TOWN_APPROVE') {
+      await post('/batches/town-approve', body)
+    } else {
+      await post('/batches/leader-approve', body)
+    }
+  } else {
+    await post('/special-biz/leader-approve', body)
+  }
+  return { ok: true }
+}
+
+/** —— 状态维护 / 标准 —— */
+export async function fetchStatusBatches(): Promise<VillageBatch[]> {
+  const all = await fetchVillageBatches()
+  return all.filter((b) =>
+    ['分管领导审批', '待线下发放', '回盘信息确认'].includes(b.stage),
+  )
+}
+
+const standardIdMap = new Map<number, string>()
+
+export async function fetchStandards(town?: string): Promise<SubsidyStandard[]> {
+  await ensureTowns()
+  const townId = town ? townIdOf(town) : undefined
+  // 后端返回 PageResult,不是裸数组
+  const page = await get<PageResult<Record<string, unknown>>>('/amount-standards', {
+    townId: townId || undefined,
+    pageNum: 1,
+    pageSize: 200,
+  })
+  const rows = Array.isArray(page) ? page : page?.list || []
+  const list = rows.map((r) => {
+    const m = mapStandard(r)
+    standardIdMap.set(m.id, String(r.id))
+    return m
+  })
+  return list.sort((a, b) => (b.year !== a.year ? b.year - a.year : b.date.localeCompare(a.date)))
+}
+
+export async function createStandard(payload: {
+  town: string
+  year: number
+  monthly: number
+  funeral: number
+  date: string
+  reason: string
+}) {
+  const townId = townIdOf(payload.town)
+  if (!townId) return { ok: false as const, message: '未识别街镇' }
+  await post('/amount-standards/yearly-adjust', {
+    townId,
+    monthlyAmount: payload.monthly,
+    funeralAmount: payload.funeral,
+    effectiveMonth: `${payload.year}01`,
+    adjustReason: payload.reason,
+    operator: operatorOf(),
+  }).catch(async () => {
+    await post('/amount-standards', {
+      townId,
+      standardType: 'YEARLY_ADJUST',
+      amount: payload.monthly,
+      funeralAmount: payload.funeral,
+      effectiveTime: payload.date,
+      adjustReason: payload.reason,
+      operator: operatorOf(),
+    })
+  })
+  return { ok: true as const }
+}
+
+export async function updateStandard(payload: {
+  id: number
+  monthly: number
+  funeral: number
+  date: string
+  reason: string
+}) {
+  const realId = standardIdMap.get(payload.id)
+  await post('/amount-standards', {
+    id: realId,
+    amount: payload.monthly,
+    funeralAmount: payload.funeral,
+    effectiveTime: payload.date,
+    adjustReason: payload.reason,
+    operator: operatorOf(),
+  })
+  return { ok: true as const }
+}
+
+export async function updateDeathStatus(deathId: string) {
+  // deathId 可能是人员 ID 或告警 ID
+  try {
+    await put('/personnel/status', {
+      personnelId: deathId,
+      bizStatus: 'STOPPED',
+      payThisMonth: false,
+      remarks: '已完成死亡状态更新,停止发放',
+      operator: operatorOf(),
+    })
+  } catch {
+    /* ignore */
+  }
+  return { ok: true }
+}
+
+export async function applyPersonStatusAction(payload: {
+  personId: number | string
+  action: string
+  amount?: number
+  reason?: string
+}) {
+  const personnelId = personIdStr(payload.personId)
+  const map: Record<string, string> = {
+    停止: 'STOP',
+    '暂停/停止': 'STOP',
+    暂停: 'PAUSE',
+    恢复: 'RESUME',
+    丧葬: 'FUNERAL',
+    死亡状态更新: 'FUNERAL',
+    补发: 'SUPPLEMENT',
+    调标补发: 'STANDARD_ADJUST',
+  }
+  const bizType = map[payload.action]
+  if (bizType === 'STOP' || bizType === 'PAUSE' || bizType === 'RESUME' || bizType === 'FUNERAL' || bizType === 'SUPPLEMENT') {
+    await post('/special-biz', {
+      personnelId,
+      bizType,
+      reason: payload.reason || payload.action,
+      amount: payload.amount,
+      operator: operatorOf(),
+    })
+  }
+  return { ok: true }
+}
+
+/** —— 特殊业务 —— */
+export async function fetchSpecials(): Promise<SpecialBiz[]> {
+  await ensureTowns()
+  const res = await get<PageResult<Record<string, unknown>>>('/special-biz/page', {
+    pageNum: 1,
+    pageSize: 200,
+  })
+  return (res?.list || []).map(mapSpecial)
+}
+
+export async function fetchDeaths(): Promise<DeathRecord[]> {
+  await ensureTowns()
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/personnel/page', {
+      pageNum,
+      pageSize,
+      bizStatus: 'FUNERAL',
+    }),
+  )
+  return list.map((p) => ({
+    id: String(p.id || ''),
+    personName: String(p.name || ''),
+    idCard: String(p.idNumber || ''),
+    town: regionName(String(p.townId || '')),
+    village: regionName(String(p.villageId || '')),
+    deathDate: String(p.deathDate || ''),
+    heirName: p.heirName ? String(p.heirName) : '',
+    heirIdCard: p.heirIdNumber ? String(p.heirIdNumber) : '',
+    heirBankCard: p.heirBankCard ? String(p.heirBankCard) : '',
+    heirContact: p.heirPhone ? String(p.heirPhone) : '',
+    source: String(p.source || '社区事务受理服务中心手工录入'),
+    status: '待死亡状态更新',
+    createdAt: '',
+  }))
+}
+
+export async function createDeathRecord(payload: {
+  personName: string
+  idCard: string
+  town: string
+  village: string
+  deathDate: string
+  heirName?: string
+  heirIdCard?: string
+  heirBankCard?: string
+  heirContact?: string
+}) {
+  const { townId, villageId } = await resolveScopeIds(payload.town, payload.village)
+  // 先按身份证找人
+  const found = await post<PageResult<Record<string, unknown>>>('/personnel/page', {
+    pageNum: 1,
+    pageSize: 5,
+    idNumber: payload.idCard,
+    townId: townId || undefined,
+  })
+  let personnelId = found?.list?.[0]?.id ? String(found.list[0].id) : ''
+  if (!personnelId) {
+    const created = await post<Record<string, unknown>>('/personnel', {
+      name: payload.personName,
+      idNumber: payload.idCard,
+      townId,
+      villageId,
+      deathDate: payload.deathDate,
+      heirName: payload.heirName,
+      heirIdNumber: payload.heirIdCard,
+      heirBankCard: payload.heirBankCard,
+      heirPhone: payload.heirContact,
+      source: '死亡名单',
+      remarks: '死亡人员信息录入',
+    })
+    personnelId = String(created?.id || '')
+  } else {
+    await put('/personnel/status', {
+      personnelId,
+      bizStatus: 'FUNERAL',
+      payThisMonth: false,
+      remarks: `死亡日期 ${payload.deathDate}`,
+      operator: operatorOf(),
+    })
+  }
+  return { ok: true, id: personnelId }
+}
+
+export async function createSpecialBiz(payload: {
+  type: string
+  personName: string
+  town: string
+  village: string
+  reason: string
+  amount: number
+  heirName?: string
+}) {
+  const { townId, villageId } = await resolveScopeIds(payload.town, payload.village)
+  const bizType = specialTypeToCode(payload.type)
+  let personnelId: string | undefined
+  if (bizType !== 'STANDARD_ADJUST') {
+    const people = await post<PageResult<Record<string, unknown>>>('/personnel/page', {
+      pageNum: 1,
+      pageSize: 20,
+      name: payload.personName,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+    })
+    personnelId = people?.list?.[0]?.id ? String(people.list[0].id) : undefined
+    if (!personnelId) throw new Error(`未找到人员:${payload.personName}`)
+  }
+  const created = await post<Record<string, unknown>>('/special-biz', {
+    personnelId,
+    bizType,
+    reason: payload.reason,
+    amount: payload.amount,
+    townId: townId || undefined,
+    heirName: payload.heirName,
+    operator: operatorOf(),
+  })
+  return { ok: true, id: String(created?.id || '') }
+}
+
+export async function exportSpecialSummary(type?: string) {
+  return {
+    ok: true,
+    fileName: type
+      ? `${currentPayMonth()}-${type}汇总文件.xlsx`
+      : '特殊业务汇总文件.xlsx',
+  }
+}
+
+export async function triggerAdjustReissue(town?: string) {
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const townName = town || role?.town || '叶榭镇'
+  const { townId } = await resolveScopeIds(townName)
+  if (!townId) return { ok: false, message: '未识别街镇', batchNo: currentPayMonth(), count: 0, villageCount: 0 }
+  const created = await post<Record<string, unknown>>('/special-biz', {
+    bizType: 'STANDARD_ADJUST',
+    townId,
+    reason: '年度调标差额补发',
+    operator: operatorOf(),
+  })
+  const related = String(created?.relatedBatchIds || '')
+  const villageCount = related ? related.split(/[,,]/).filter(Boolean).length : 0
+  return {
+    ok: true,
+    batchNo: String(created?.batchNo || currentPayMonth()),
+    villageCount,
+    count: Number(created?.amount ? 0 : 0) || villageCount,
+    message: undefined as string | undefined,
+  }
+}
+
+/** 从备注解析旧/新月标(批次或明细备注:旧月标1280|新月标2113) */
+function parseOldNewMonthly(text?: string): { oldMonthly: number; newMonthly: number } | null {
+  const s = String(text || '')
+  const oldM = s.match(/旧月标\s*([0-9]+(?:\.[0-9]+)?)/)
+  const newM = s.match(/新月标\s*([0-9]+(?:\.[0-9]+)?)/)
+  if (!oldM || !newM) return null
+  const oldMonthly = Number(oldM[1])
+  const newMonthly = Number(newM[1])
+  if (!(newMonthly > 0) || !(oldMonthly >= 0)) return null
+  return { oldMonthly, newMonthly }
+}
+
+/** 从明细备注解析应发月数与单月差额 */
+function parseAdjustMonthsDiff(text?: string): { months: number; diff: number } | null {
+  const s = String(text || '')
+  const m = s.match(/应发\s*(\d+)\s*月\s*[×xX*]\s*差额\s*([0-9]+(?:\.[0-9]+)?)/)
+  if (!m) return null
+  return { months: Number(m[1]), diff: Number(m[2]) }
+}
+
+/** 从金额标准表取当前新标 + 上一档旧标 */
+async function loadTownAdjustStandards(townId: string): Promise<{
+  oldMonthly: number
+  newMonthly: number
+} | null> {
+  try {
+    const page = await get<PageResult<Record<string, unknown>>>('/amount-standards', {
+      townId,
+      pageNum: 1,
+      pageSize: 100,
+    })
+    const list = (page?.list || []).filter((r) => {
+      const t = String(r.standardType || '')
+      return (t === 'YEARLY_ADJUST' || t === 'MONTHLY') && Number(r.amount || 0) > 0
+    })
+    if (!list.length) return null
+    list.sort((a, b) => Number(b.effectiveTime || 0) - Number(a.effectiveTime || 0))
+    const cur =
+      list.find((r) => r.current === true || r.current === 'true') || list[0]
+    const curAmt = Number(cur.amount || 0)
+    const curEff = Number(cur.effectiveTime || 0)
+    const prev = list.find((r) => {
+      if (String(r.id) === String(cur.id)) return false
+      const amt = Number(r.amount || 0)
+      if (!(amt > 0)) return false
+      const eff = Number(r.effectiveTime || 0)
+      return !curEff || !eff || eff < curEff
+    })
+    if (!prev) return null
+    const oldMonthly = Number(prev.amount || 0)
+    if (!(curAmt > oldMonthly)) return null
+    return { oldMonthly, newMonthly: curAmt }
+  } catch {
+    return null
+  }
+}
+
+export async function fetchAdjustReissueRoster(params?: {
+  town?: string
+  batchNo?: string
+  village?: string
+}): Promise<AdjustReissuePerson[]> {
+  await ensureTowns()
+  const { townId, villageId } = await resolveScopeIds(params?.town, params?.village)
+  const townStandards = townId ? await loadTownAdjustStandards(townId) : null
+
+  // 调标村批一般为 YYYY99 或备注含调标;按发放明细补发额组装
+  const batches = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/batches/page', {
+      pageNum,
+      pageSize,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      batchLevel: 'VILLAGE',
+      batchNo: params?.batchNo,
+    }),
+  )
+  const adjustBatches = batches.filter((b) => {
+    const no = String(b.batchNo || b.payMonth || '')
+    const remarks = String(b.remarks || '')
+    const id = String(b.id || '')
+    return (
+      no.endsWith('99') ||
+      id.includes('99') ||
+      remarks.includes('YEARLY_ADJUST') ||
+      remarks.includes('调标') ||
+      Number(b.supplementAmount || 0) > 0
+    )
+  })
+  const roster: AdjustReissuePerson[] = []
+  for (const b of adjustBatches) {
+    if (params?.village && regionName(String(b.villageId || '')) !== params.village) continue
+    const batchBasis =
+      parseOldNewMonthly(String(b.remarks || '')) || townStandards
+    const details = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+      post<PageResult<Record<string, unknown>>>('/payments/details/page', {
+        pageNum,
+        pageSize,
+        batchId: b.id,
+      }),
+    )
+    for (const d of details) {
+      const amt = Number(d.supplementAmount ?? d.totalAmount ?? 0)
+      if (!amt) continue
+      const detailBasis = parseOldNewMonthly(String(d.remarks || ''))
+      const monthsDiff = parseAdjustMonthsDiff(String(d.remarks || ''))
+      let newStandard = 0
+      let oldStandard = 0
+
+      if (detailBasis) {
+        oldStandard = detailBasis.oldMonthly
+        newStandard = detailBasis.newMonthly
+      } else if (batchBasis) {
+        oldStandard = batchBasis.oldMonthly
+        newStandard = batchBasis.newMonthly
+      } else if (monthsDiff && monthsDiff.diff > 0) {
+        // 仅有差额时:明细月标若为新标则反推旧标;否则用差额反推
+        const monthly = Number(d.monthlyAmount || 0)
+        if (monthly > monthsDiff.diff) {
+          newStandard = monthly
+          oldStandard = Math.max(0, monthly - monthsDiff.diff)
+        } else {
+          newStandard = monthsDiff.diff
+          oldStandard = 0
+        }
+      } else {
+        const monthly = Number(d.monthlyAmount || 0)
+        if (monthly > 0) {
+          // 历史脏数据曾把月标写成 0;若已有新标则用差额反推
+          newStandard = monthly
+          const months = monthsDiff?.months || 0
+          const unitDiff = months > 0 ? amt / months : 0
+          oldStandard = Math.max(0, monthly - (unitDiff || 0))
+        }
+      }
+
+      // 仍缺新标时:用「补发额 / 月数 + 旧标」补全
+      if (newStandard <= 0 && oldStandard > 0 && monthsDiff?.months) {
+        newStandard = oldStandard + amt / monthsDiff.months
+      }
+      if (oldStandard <= 0 && newStandard > 0 && monthsDiff?.months) {
+        oldStandard = Math.max(0, newStandard - amt / monthsDiff.months)
+      }
+      if (newStandard <= 0 && monthsDiff?.diff) {
+        newStandard = (oldStandard || 0) + monthsDiff.diff
+      }
+      if (oldStandard <= 0 && newStandard > 0 && monthsDiff?.diff) {
+        oldStandard = Math.max(0, newStandard - monthsDiff.diff)
+      }
+
+      roster.push({
+        id: String(d.id || ''),
+        batchNo: String(b.batchNo || b.payMonth || params?.batchNo || ''),
+        personId: String(d.personnelId || '') as unknown as number,
+        name: String(d.name || ''),
+        idCard: String(d.idNumber || ''),
+        town: regionName(String(d.townId || b.townId || '')),
+        village: regionName(String(d.villageId || b.villageId || '')),
+        oldStandard: Number(oldStandard.toFixed(2)),
+        newStandard: Number(newStandard.toFixed(2)),
+        amount: amt,
+        status: '待提交',
+        remark: '年度调标差额补发',
+      })
+    }
+  }
+  return roster
+}
+
+/** —— 月补贴 —— */
+export async function fetchVillageMonthlyBatches(town?: string): Promise<VillageMonthlyBatch[]> {
+  await ensureTowns()
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const { townId } = await resolveScopeIds(town || role?.town)
+  if (townId) await ensureVillages(townId)
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/batches/page', {
+      pageNum,
+      pageSize,
+      townId: townId || undefined,
+      batchLevel: 'VILLAGE',
+    }),
+  )
+  const mapped = list
+    .map(mapVillageMonthly)
+    .filter((b) =>
+      ['待提交', '待审核', '已审核', '已出盘', '已回盘'].includes(b.status),
+    )
+  // 后端 PENDING_STATUS_MAINTAIN / 匹配后未进领导审的也可视为待提交
+  const extra = list
+    .filter((r) => {
+      const st = String(r.status || '')
+      return st === 'PENDING_STATUS_MAINTAIN' || st === 'PENDING_LEADER_APPROVE' ||
+        st === 'READY_EXPORT' || st === 'EXPORTED' || st === 'RETURNED' || st === 'ARCHIVED'
+    })
+    .map(mapVillageMonthly)
+  const byId = new Map<string, VillageMonthlyBatch>()
+  ;[...mapped, ...extra].forEach((x) => byId.set(x.id, x))
+  return [...byId.values()]
+}
+
+export async function fetchMonthlyBatches(): Promise<MonthlyBatch[]> {
+  const villages = await fetchVillageMonthlyBatches()
+  const map = new Map<string, MonthlyBatch>()
+  for (const v of villages) {
+    const key = `${v.batchNo}|${v.town}`
+    const cur = map.get(key)
+    if (!cur) {
+      map.set(key, {
+        id: `MB${v.batchNo}${townIdOf(v.town)}`,
+        batchNo: v.batchNo,
+        month: v.month,
+        town: v.town,
+        peopleCount: v.peopleCount,
+        normalAmount: v.normalAmount,
+        extraAmount: v.extraAmount,
+        deductAmount: v.deductAmount,
+        totalAmount: v.totalAmount,
+        status: v.status,
+      })
+    } else {
+      cur.peopleCount += v.peopleCount
+      cur.normalAmount += v.normalAmount
+      cur.extraAmount += v.extraAmount
+      cur.deductAmount += v.deductAmount
+      cur.totalAmount += v.totalAmount
+    }
+  }
+  return [...map.values()]
+}
+
+export async function calculateMonthly() {
+  await post('/batches/ensure-current-month-all', undefined, {
+    params: { payMonth: currentPayMonth() },
+  }).catch(() => undefined)
+  return { ok: true }
+}
+
+export async function confirmMonthly(id: string) {
+  await post('/payments/export-disk', { batchId: id, bypassDeadline: true }).catch(() => undefined)
+  return { ok: true }
+}
+
+export async function exportMonthlyFile(id?: string) {
+  if (!id) {
+    return { ok: false as const, message: '缺少批次ID', fileName: '' }
+  }
+  try {
+    const res = await post<Record<string, unknown>>('/payments/export-disk', {
+      batchId: id,
+      bypassDeadline: true,
+    })
+    return {
+      ok: true as const,
+      fileName: String(res?.fileName || res?.exportDiskNo || `${currentPayMonth()}-月补贴发放文件`),
+      message: String(res?.offlineTip || ''),
+    }
+  } catch (e) {
+    return {
+      ok: false as const,
+      fileName: '',
+      message: e instanceof Error ? e.message : '导出失败',
+    }
+  }
+}
+
+export async function issueVillageMonthlyDisk(batchId: string) {
+  const res = await post<Record<string, unknown>>('/payments/export-disk', {
+    batchId,
+    bypassDeadline: true,
+  })
+  return {
+    ok: true as const,
+    fileName: String(res?.fileName || res?.exportDiskNo || `${currentPayMonth()}-月补贴发放文件`),
+    message: String(res?.offlineTip || ''),
+  }
+}
+
+export async function confirmVillageMonthlyReturn(payload: {
+  batchId: string
+  failCount?: number
+}) {
+  await post('/return-disk/confirm', {
+    batchId: payload.batchId,
+    manualAllSuccess: !payload.failCount,
+    operator: operatorOf(),
+  })
+  return { ok: true }
+}
+
+/** —— 回盘 —— */
+export async function fetchDisks(town?: string): Promise<DiskRecord[]> {
+  await ensureTowns()
+  const { townId } = await resolveScopeIds(town)
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/batches/page', {
+      pageNum,
+      pageSize,
+      townId: townId || undefined,
+      batchLevel: 'VILLAGE',
+    }),
+  )
+  return list
+    .filter((b) => ['EXPORTED', 'RETURNED', 'ARCHIVED'].includes(String(b.status || '')))
+    .map(mapDisk)
+    .sort((a, b) => b.batchNo.localeCompare(a.batchNo))
+}
+
+export async function exportDisk() {
+  return { ok: true }
+}
+
+export async function confirmReturnDisk(payload?: { id?: string; failCount?: number }) {
+  if (!payload?.id) return { ok: false }
+  await post('/return-disk/confirm', {
+    batchId: payload.id,
+    manualAllSuccess: !payload.failCount,
+    operator: operatorOf(),
+  })
+  return { ok: true }
+}
+
+export async function uploadReturnDiskFile(payload: { id: string; file: File }) {
+  const form = new FormData()
+  form.append('file', payload.file)
+  const uploaded = await post<Record<string, unknown>>(
+    `/return-disk/upload?batchId=${encodeURIComponent(payload.id)}`,
+    form as unknown as Record<string, unknown>,
+  )
+  // 上传解析后确认回盘(对齐旧前端:导入+确认)
+  const confirmed = await post<Record<string, unknown>>('/return-disk/confirm', {
+    batchId: payload.id,
+    fileName: payload.file.name,
+    operator: operatorOf(),
+  }).catch(() => uploaded)
+  return {
+    ok: true as const,
+    failCount: Number(confirmed?.failCount ?? uploaded?.failCount ?? 0),
+    alertCount: Number(confirmed?.alertCount ?? 0),
+    message: String(confirmed?.message || uploaded?.message || '回盘文件已处理'),
+  }
+}
+
+export async function fetchDiskFailPeople(diskId: string): Promise<DiskFailPerson[]> {
+  const details = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/return-disk/details/page', {
+      pageNum,
+      pageSize,
+      batchId: diskId,
+    }),
+  )
+  return details
+    .filter((d) => d.success === false)
+    .map((d) => mapReturnFail(d, diskId))
+}
+
+export async function confirmDiskFailPaid(failPersonId: string) {
+  // 纳入待补发/已确认:走待补发台账 reissue 或仅标记
+  try {
+    await post('/return-arrears/reissue', {
+      arrearsIds: [failPersonId],
+      operator: operatorOf(),
+    })
+  } catch {
+    /* 明细 ID 可能不是台账 ID,忽略 */
+  }
+  return { ok: true as const }
+}
+
+export async function archiveToNextMonth(id?: string) {
+  if (id) await post(`/dashboard/archive/${encodeURIComponent(id)}`)
+  return { ok: true }
+}
+
+/** —— 告警 —— */
+export async function fetchAlerts(): Promise<AlertItem[]> {
+  await ensureTowns()
+  const list = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
+    post<PageResult<Record<string, unknown>>>('/alerts/page', { pageNum, pageSize }),
+  )
+  return list.map(mapAlert)
+}
+
+export async function handleAlert(payload: HandleAlertPayload | string, result?: string) {
+  const data: HandleAlertPayload =
+    typeof payload === 'string'
+      ? {
+          id: payload,
+          personName: '',
+          idCard: '',
+          town: '',
+          village: '',
+          bankCard: '',
+          bankName: '',
+          socialInsuranceCode: '',
+          heirName: '',
+          heirIdCard: '',
+          heirBankCard: '',
+          heirContact: '',
+          result,
+        }
+      : payload
+
+  await post('/alerts/handle', {
+    alertId: data.id,
+    handleStatus: 'CLOSED',
+    handleResult: data.result || '已核实并处理',
+    handler: operatorOf(),
+  })
+
+  // 同步人员信息(已有字段范围内)
+  if (data.idCard) {
+    const found = await post<PageResult<Record<string, unknown>>>('/personnel/page', {
+      pageNum: 1,
+      pageSize: 5,
+      idNumber: data.idCard,
+    })
+    const p = found?.list?.[0]
+    if (p?.id) {
+      const { townId, villageId } = await resolveScopeIds(data.town, data.village)
+      await post('/personnel', {
+        id: p.id,
+        name: data.personName || p.name,
+        idNumber: data.idCard || p.idNumber,
+        townId: townId || p.townId,
+        villageId: villageId || p.villageId,
+        bankCardNumber: data.bankCard || p.bankCardNumber,
+        bankName: data.bankName || p.bankName,
+        socialInsuranceCode: data.socialInsuranceCode || p.socialInsuranceCode,
+        heirName: data.heirName || p.heirName,
+        heirIdNumber: data.heirIdCard || p.heirIdNumber,
+        heirBankCard: data.heirBankCard || p.heirBankCard,
+        heirPhone: data.heirContact || p.heirPhone,
+        monthlyStandard: p.monthlyStandard,
+        enjoyStartMonth: p.enjoyStartMonth,
+        source: p.source,
+        remarks: p.remarks,
+      }).catch(() => undefined)
+    }
+  }
+  return { ok: true }
+}
+
+/** —— 报表 / 系统 —— */
+export async function fetchReportBatches(): Promise<VillageBatch[]> {
+  return fetchVillageBatches()
+}
+
+/** —— 系统管理员运维 —— */
+export async function listRegionsAdmin(params?: {
+  level?: string
+  parentId?: string
+}): Promise<RegionRow[]> {
+  const data = await get<RegionRow[]>('/regions', {
+    level: params?.level,
+    parentId: params?.parentId,
+  })
+  return data || []
+}
+
+export async function saveRegionAdmin(payload: {
+  id?: string
+  code: string
+  name: string
+  level: string
+  parentId?: string
+  entrustPlanCode?: string
+  defaultBank?: string
+  enabled?: boolean
+  remarks?: string
+}) {
+  await post('/regions', payload)
+  return { ok: true as const }
+}
+
+export async function deleteRegionAdmin(id: string) {
+  await del(`/regions/${encodeURIComponent(id)}`)
+  return { ok: true as const }
+}
+
+export async function pageAdminBatches(params?: {
+  payMonth?: string
+  townId?: string
+  villageId?: string
+  batchLevel?: string
+  status?: string
+}) {
+  await ensureTowns()
+  const res = await post<PageResult<Record<string, unknown>>>('/batches/page', {
+    pageNum: 1,
+    pageSize: 500,
+    payMonth: params?.payMonth || undefined,
+    townId: params?.townId || undefined,
+    villageId: params?.villageId || undefined,
+    batchLevel: params?.batchLevel || undefined,
+    status: params?.status || undefined,
+  })
+  return (res?.list || []).map((b) => ({
+    id: String(b.id || ''),
+    batchNo: String(b.batchNo || b.payMonth || ''),
+    payMonth: String(b.payMonth || ''),
+    batchLevel: String(b.batchLevel || 'VILLAGE'),
+    townId: String(b.townId || ''),
+    villageId: String(b.villageId || ''),
+    town: regionName(String(b.townId || '')),
+    village: regionName(String(b.villageId || '')),
+    peopleCount: Number(b.peopleCount || 0),
+    totalAmount: Number(b.totalAmount || 0),
+    status: String(b.status || ''),
+    currentStage: String(b.currentStage || ''),
+  }))
+}
+
+export async function purgeBatchAdmin(batchId: string) {
+  await post(`/batches/${encodeURIComponent(batchId)}/purge`)
+  return { ok: true as const }
+}
+
+export async function deletePersonnelAdmin(personnelId: string) {
+  await del(`/personnel/${encodeURIComponent(personnelId)}`)
+  return { ok: true as const }
+}
+
+export async function updatePersonnelStatusAdmin(payload: {
+  personnelId: string
+  bizStatus: string
+  payThisMonth?: boolean
+  remarks?: string
+}) {
+  await put('/personnel/status', payload)
+  return { ok: true as const }
+}
+
+export async function fetchAccounts(): Promise<AccountItem[]> {
+  // 后端无账号管理接口:返回空,页面可空态展示
+  return []
+}
+
+export async function fetchOpLogs(): Promise<OpLog[]> {
+  const res = await post<PageResult<Record<string, unknown>>>('/process-logs/page', {
+    pageNum: 1,
+    pageSize: 100,
+  })
+  return (res?.list || []).map((r, idx) => ({
+    id: idx + 1,
+    time: String(r.operateTime || r.createTime || ''),
+    user: String(r.operator || ''),
+    role: String(r.operatorRole || ''),
+    module: String(r.bizType || r.module || '流程'),
+    action: String(r.action || r.opinion || ''),
+    result: String(r.result || '成功'),
+  }))
+}

+ 111 - 0
src/api/sjnmtybt/regionStore.ts

@@ -0,0 +1,111 @@
+/** Region cache: UI Chinese names <-> backend region ids. */
+import { get } from '@/utils/request'
+
+export interface RegionRow {
+  id: string
+  code?: string
+  name: string
+  level?: string
+  parentId?: string
+}
+
+const FALLBACK_NAME: Record<string, string> = {
+  D_SJ: '松江区',
+  T_YEXIE: '叶榭镇',
+  T_SHESHAN: '佘山镇',
+  T_CHEDUN: '车墩镇',
+  T_SHIHUDANG: '石湖荡镇',
+  T_MAOGANG: '泖港镇',
+  T_XINBANG: '新浜镇',
+  T_XIAOKUNSHAN: '小昆山镇',
+  V_YX_001: '叶榭村',
+  V_YX_002: '井凌桥村',
+  V_SS_001: '陈坊村',
+  V_CD_001: '车墩村',
+}
+
+const towns: RegionRow[] = []
+const villagesByTown = new Map<string, RegionRow[]>()
+const nameToTownId = new Map<string, string>()
+const nameToVillageId = new Map<string, string>()
+const idToName = new Map<string, string>()
+
+let townsLoaded = false
+
+function remember(row: RegionRow) {
+  const name = (row.name && String(row.name).trim()) || FALLBACK_NAME[row.id] || row.id
+  idToName.set(row.id, name)
+  if (row.code) idToName.set(row.code, name)
+  if (row.level === 'TOWN' || row.id.startsWith('T_')) {
+    nameToTownId.set(name, row.id)
+  }
+}
+
+export function regionName(id?: string | null): string {
+  if (!id) return ''
+  return idToName.get(id) || FALLBACK_NAME[id] || id
+}
+
+export function townIdOf(nameOrId?: string): string {
+  if (!nameOrId) return ''
+  if (nameOrId.startsWith('T_') || nameOrId.startsWith('D_')) return nameOrId
+  return nameToTownId.get(nameOrId) || ''
+}
+
+export function villageIdOf(townNameOrId: string | undefined, villageNameOrId?: string): string {
+  if (!villageNameOrId) return ''
+  if (villageNameOrId.startsWith('V_')) return villageNameOrId
+  const tid = townIdOf(townNameOrId)
+  if (!tid) return ''
+  return nameToVillageId.get(`${tid}|${villageNameOrId}`) || ''
+}
+
+export function listTownNames(): string[] {
+  // 仅返回接口已加载的街镇,不回退写死列表
+  return towns.map((t) => regionName(t.id)).filter(Boolean)
+}
+
+export function listVillageNames(townNameOrId?: string): string[] {
+  const tid = townIdOf(townNameOrId)
+  if (!tid) return []
+  const list = villagesByTown.get(tid) || []
+  // 仅返回接口已加载的村居,不回退写死村名
+  return list.map((v) => regionName(v.id)).filter(Boolean)
+}
+
+export async function ensureTowns(): Promise<RegionRow[]> {
+  if (townsLoaded && towns.length) return towns
+  const data = await get<RegionRow[]>('/regions', { level: 'TOWN', parentId: 'D_SJ' })
+  towns.splice(0, towns.length, ...(data || []))
+  towns.forEach(remember)
+  Object.entries(FALLBACK_NAME).forEach(([id, name]) => {
+    if (!idToName.has(id)) idToName.set(id, name)
+    if (id.startsWith('T_')) nameToTownId.set(name, id)
+  })
+  townsLoaded = true
+  return towns
+}
+
+export async function ensureVillages(townNameOrId: string): Promise<RegionRow[]> {
+  await ensureTowns()
+  const tid = townIdOf(townNameOrId)
+  if (!tid) return []
+  if (villagesByTown.has(tid)) return villagesByTown.get(tid)!
+  const data = await get<RegionRow[]>('/regions', { level: 'VILLAGE', parentId: tid })
+  const list = data || []
+  villagesByTown.set(tid, list)
+  list.forEach((v) => {
+    remember(v)
+    const name = regionName(v.id)
+    nameToVillageId.set(`${tid}|${name}`, v.id)
+  })
+  return list
+}
+
+export async function resolveScopeIds(town?: string, village?: string) {
+  await ensureTowns()
+  const townId = townIdOf(town)
+  if (townId && village) await ensureVillages(townId)
+  const villageId = villageIdOf(townId || town, village)
+  return { townId, villageId }
+}

+ 62 - 62
src/auto-imports.d.ts

@@ -6,73 +6,73 @@
 // biome-ignore lint: disable
 export {}
 declare global {
-  const EffectScope: typeof import('vue').EffectScope
-  const computed: typeof import('vue').computed
-  const createApp: typeof import('vue').createApp
-  const customRef: typeof import('vue').customRef
-  const defineAsyncComponent: typeof import('vue').defineAsyncComponent
-  const defineComponent: typeof import('vue').defineComponent
-  const effectScope: typeof import('vue').effectScope
-  const getCurrentInstance: typeof import('vue').getCurrentInstance
-  const getCurrentScope: typeof import('vue').getCurrentScope
+  const EffectScope: typeof import('vue')['EffectScope']
+  const computed: typeof import('vue')['computed']
+  const createApp: typeof import('vue')['createApp']
+  const customRef: typeof import('vue')['customRef']
+  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+  const defineComponent: typeof import('vue')['defineComponent']
+  const effectScope: typeof import('vue')['effectScope']
+  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+  const getCurrentScope: typeof import('vue')['getCurrentScope']
   const getCurrentWatcher: typeof import('vue').getCurrentWatcher
-  const h: typeof import('vue').h
-  const inject: typeof import('vue').inject
-  const isProxy: typeof import('vue').isProxy
-  const isReactive: typeof import('vue').isReactive
-  const isReadonly: typeof import('vue').isReadonly
-  const isRef: typeof import('vue').isRef
+  const h: typeof import('vue')['h']
+  const inject: typeof import('vue')['inject']
+  const isProxy: typeof import('vue')['isProxy']
+  const isReactive: typeof import('vue')['isReactive']
+  const isReadonly: typeof import('vue')['isReadonly']
+  const isRef: typeof import('vue')['isRef']
   const isShallow: typeof import('vue').isShallow
-  const markRaw: typeof import('vue').markRaw
-  const nextTick: typeof import('vue').nextTick
-  const onActivated: typeof import('vue').onActivated
-  const onBeforeMount: typeof import('vue').onBeforeMount
-  const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
-  const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
-  const onBeforeUnmount: typeof import('vue').onBeforeUnmount
-  const onBeforeUpdate: typeof import('vue').onBeforeUpdate
-  const onDeactivated: typeof import('vue').onDeactivated
-  const onErrorCaptured: typeof import('vue').onErrorCaptured
-  const onMounted: typeof import('vue').onMounted
-  const onRenderTracked: typeof import('vue').onRenderTracked
-  const onRenderTriggered: typeof import('vue').onRenderTriggered
-  const onScopeDispose: typeof import('vue').onScopeDispose
-  const onServerPrefetch: typeof import('vue').onServerPrefetch
-  const onUnmounted: typeof import('vue').onUnmounted
-  const onUpdated: typeof import('vue').onUpdated
-  const onWatcherCleanup: typeof import('vue').onWatcherCleanup
-  const provide: typeof import('vue').provide
-  const reactive: typeof import('vue').reactive
-  const readonly: typeof import('vue').readonly
-  const ref: typeof import('vue').ref
-  const resolveComponent: typeof import('vue').resolveComponent
-  const shallowReactive: typeof import('vue').shallowReactive
-  const shallowReadonly: typeof import('vue').shallowReadonly
-  const shallowRef: typeof import('vue').shallowRef
-  const toRaw: typeof import('vue').toRaw
-  const toRef: typeof import('vue').toRef
-  const toRefs: typeof import('vue').toRefs
-  const toValue: typeof import('vue').toValue
-  const triggerRef: typeof import('vue').triggerRef
-  const unref: typeof import('vue').unref
-  const useAttrs: typeof import('vue').useAttrs
-  const useCssModule: typeof import('vue').useCssModule
-  const useCssVars: typeof import('vue').useCssVars
-  const useId: typeof import('vue').useId
-  const useLink: typeof import('vue-router').useLink
-  const useModel: typeof import('vue').useModel
-  const useRoute: typeof import('vue-router').useRoute
-  const useRouter: typeof import('vue-router').useRouter
-  const useSlots: typeof import('vue').useSlots
-  const useTemplateRef: typeof import('vue').useTemplateRef
-  const watch: typeof import('vue').watch
-  const watchEffect: typeof import('vue').watchEffect
-  const watchPostEffect: typeof import('vue').watchPostEffect
-  const watchSyncEffect: typeof import('vue').watchSyncEffect
+  const markRaw: typeof import('vue')['markRaw']
+  const nextTick: typeof import('vue')['nextTick']
+  const onActivated: typeof import('vue')['onActivated']
+  const onBeforeMount: typeof import('vue')['onBeforeMount']
+  const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
+  const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
+  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+  const onDeactivated: typeof import('vue')['onDeactivated']
+  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+  const onMounted: typeof import('vue')['onMounted']
+  const onRenderTracked: typeof import('vue')['onRenderTracked']
+  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+  const onScopeDispose: typeof import('vue')['onScopeDispose']
+  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+  const onUnmounted: typeof import('vue')['onUnmounted']
+  const onUpdated: typeof import('vue')['onUpdated']
+  const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
+  const provide: typeof import('vue')['provide']
+  const reactive: typeof import('vue')['reactive']
+  const readonly: typeof import('vue')['readonly']
+  const ref: typeof import('vue')['ref']
+  const resolveComponent: typeof import('vue')['resolveComponent']
+  const shallowReactive: typeof import('vue')['shallowReactive']
+  const shallowReadonly: typeof import('vue')['shallowReadonly']
+  const shallowRef: typeof import('vue')['shallowRef']
+  const toRaw: typeof import('vue')['toRaw']
+  const toRef: typeof import('vue')['toRef']
+  const toRefs: typeof import('vue')['toRefs']
+  const toValue: typeof import('vue')['toValue']
+  const triggerRef: typeof import('vue')['triggerRef']
+  const unref: typeof import('vue')['unref']
+  const useAttrs: typeof import('vue')['useAttrs']
+  const useCssModule: typeof import('vue')['useCssModule']
+  const useCssVars: typeof import('vue')['useCssVars']
+  const useId: typeof import('vue')['useId']
+  const useLink: typeof import('vue-router')['useLink']
+  const useModel: typeof import('vue')['useModel']
+  const useRoute: typeof import('vue-router')['useRoute']
+  const useRouter: typeof import('vue-router')['useRouter']
+  const useSlots: typeof import('vue')['useSlots']
+  const useTemplateRef: typeof import('vue')['useTemplateRef']
+  const watch: typeof import('vue')['watch']
+  const watchEffect: typeof import('vue')['watchEffect']
+  const watchPostEffect: typeof import('vue')['watchPostEffect']
+  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
 }
 // for type re-export
 declare global {
   // @ts-ignore
-  export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+  export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
   import('vue')
 }

+ 6 - 6
src/components.d.ts

@@ -1,16 +1,14 @@
 /* eslint-disable */
 // @ts-nocheck
-// biome-ignore lint: disable
-// oxlint-disable
-// ------
 // Generated by unplugin-vue-components
 // Read more: https://github.com/vuejs/core/pull/3399
-
+// biome-ignore lint: disable
 export {}
 
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    BatchDetailDrawer: typeof import('./components/biz/BatchDetailDrawer.vue')['default']
     ChartBox: typeof import('./components/common/ChartBox.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAside: typeof import('element-plus/es')['ElAside']
@@ -23,6 +21,7 @@ declare module 'vue' {
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -44,13 +43,14 @@ declare module 'vue' {
     ElRow: typeof import('element-plus/es')['ElRow']
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSelect: typeof import('element-plus/es')['ElSelect']
-    ElStep: typeof import('element-plus/es')['ElStep']
-    ElSteps: typeof import('element-plus/es')['ElSteps']
+    ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElTimeline: typeof import('element-plus/es')['ElTimeline']
+    ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     PagePanel: typeof import('./components/common/PagePanel.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']

+ 419 - 0
src/components/biz/BatchDetailDrawer.vue

@@ -0,0 +1,419 @@
+<template>
+  <el-drawer
+    :model-value="modelValue"
+    :title="drawerTitle"
+    size="820px"
+    destroy-on-close
+    @update:model-value="emit('update:modelValue', $event)"
+    @closed="onClosed"
+  >
+    <el-tabs v-model="tab" v-loading="loading">
+      <el-tab-pane label="批次详情" name="detail">
+        <template v-if="batch">
+          <el-descriptions :column="2" border size="small">
+            <el-descriptions-item label="批次ID">{{ batch.id }}</el-descriptions-item>
+            <el-descriptions-item label="统一批次号">{{ batch.batchNo }}</el-descriptions-item>
+            <el-descriptions-item label="发放月份">{{ batch.month || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="流转状态">
+              <StatusTag :status="batch.stage" />
+            </el-descriptions-item>
+            <el-descriptions-item label="街镇">{{ batch.town || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="村居">{{ batch.village || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="人数">{{ batch.peopleCount }}</el-descriptions-item>
+            <el-descriptions-item label="合计金额">
+              {{ formatAmount(batch.totalAmount) }}
+            </el-descriptions-item>
+            <el-descriptions-item v-if="batch.normalAmount != null" label="正常金额">
+              {{ formatAmount(batch.normalAmount) }}
+            </el-descriptions-item>
+            <el-descriptions-item v-if="batch.supplementAmount != null" label="补发金额">
+              {{ formatAmount(batch.supplementAmount) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="出盘状态">
+              {{ batch.exportStatus || '未出盘' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="回盘状态">
+              {{ batch.returnStatus || '未回盘' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="出盘编号" :span="2">
+              {{ batch.exportDiskNo || '-' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="出盘文件" :span="2">
+              {{ batch.exportFileName || '-' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="提交人">{{ batch.submitter || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="提交时间">
+              {{ batch.submittedAt || '-' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="镇审批意见" :span="2">
+              {{ batch.townOpinion || '-' }}
+            </el-descriptions-item>
+            <el-descriptions-item label="备注" :span="2">
+              {{ displayRemarks }}
+            </el-descriptions-item>
+          </el-descriptions>
+
+          <div v-if="overview?.stageDescription" class="batch-drawer__desc">
+            {{ overview.stageDescription }}
+          </div>
+
+          <div v-if="actionLabels.length" class="batch-drawer__actions">
+            <div class="batch-drawer__actions-title">可执行动作(当前阶段建议)</div>
+            <div class="batch-drawer__actions-tags">
+              <el-tag v-for="a in actionLabels" :key="a.code" type="warning" effect="plain">
+                {{ a.label }}
+              </el-tag>
+            </div>
+            <p class="batch-drawer__tip">以上为流程提示,具体办理请在对应业务菜单操作。</p>
+          </div>
+          <el-alert
+            v-else
+            class="batch-drawer__alert"
+            type="info"
+            :closable="false"
+            title="当前无可执行动作提示"
+          />
+
+          <p class="batch-drawer__sub">本批次人员明细</p>
+          <el-table
+            v-loading="peopleLoading"
+            :data="pagedPeople"
+            border
+            stripe
+            size="small"
+            max-height="280"
+          >
+            <el-table-column prop="name" label="姓名" width="80" />
+            <el-table-column label="身份证" min-width="150">
+              <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+            </el-table-column>
+            <el-table-column label="标准(元/月)" width="100" align="right">
+              <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+            </el-table-column>
+            <el-table-column label="状态" width="110" align="center">
+              <template #default="{ row }">
+                <StatusTag :status="row.status" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip />
+          </el-table>
+          <TablePagination
+            v-model:page="peoplePage"
+            v-model:page-size="peoplePageSize"
+            :total="peopleTotal"
+          />
+        </template>
+        <el-empty v-else-if="!loading" description="未找到批次信息" />
+      </el-tab-pane>
+
+      <el-tab-pane label="流程记录" name="flow">
+        <el-timeline v-loading="flowLoading">
+          <el-timeline-item
+            v-for="(log, i) in logs"
+            :key="log.id || i"
+            :timestamp="log.operateTime || ''"
+            placement="top"
+            :type="resultType(log.result)"
+          >
+            <div class="batch-drawer__flow-item">
+              <div class="batch-drawer__flow-head">
+                <StatusTag v-if="log.stage" :status="log.stage" />
+                <span class="batch-drawer__flow-action">{{ log.action || '办理' }}</span>
+                <el-tag v-if="log.result" size="small" :type="resultType(log.result)">
+                  {{ log.result }}
+                </el-tag>
+              </div>
+              <div class="batch-drawer__flow-meta">
+                {{ log.operator || log.operatorRole || log.operatorOrg || '系统' }}
+                <template v-if="log.operatorOrg && log.operator">
+                  · {{ log.operatorOrg }}
+                </template>
+              </div>
+              <div v-if="log.opinion" class="batch-drawer__flow-opinion">{{ log.opinion }}</div>
+            </div>
+          </el-timeline-item>
+        </el-timeline>
+        <el-empty v-if="!flowLoading && !logs.length" description="暂无流程记录" />
+
+        <div class="batch-drawer__note">
+          <div class="batch-drawer__actions-title">补充备注</div>
+          <el-input
+            v-model="noteOpinion"
+            type="textarea"
+            :rows="2"
+            placeholder="可补充说明(将写入流程留痕)"
+          />
+          <el-button
+            class="batch-drawer__note-btn"
+            type="primary"
+            size="small"
+            :loading="noteSaving"
+            @click="saveNote"
+          >
+            提交备注
+          </el-button>
+        </div>
+      </el-tab-pane>
+    </el-tabs>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import StatusTag from '@/components/common/StatusTag.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
+import {
+  addBatchProcessNote,
+  fetchBatchDetail,
+  fetchBatchMemberPeople,
+  fetchBatchOverview,
+  fetchBatchTimeline,
+} from '@/api/biz'
+import { actionLabel } from '@/constants/batchActions'
+import { useClientPagination } from '@/composables/useClientPagination'
+import { formatAmount, maskIdCard } from '@/utils/mask'
+import type { BatchFlowOverview, Person, ProcessLogItem, VillageBatch } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: boolean
+    batchId: string
+    /** detail | flow */
+    initialTab?: string
+  }>(),
+  { initialTab: 'detail' },
+)
+
+const emit = defineEmits<{
+  'update:modelValue': [boolean]
+}>()
+
+const tab = ref('detail')
+const loading = ref(false)
+const flowLoading = ref(false)
+const peopleLoading = ref(false)
+const noteSaving = ref(false)
+const batch = ref<VillageBatch | null>(null)
+const overview = ref<BatchFlowOverview | null>(null)
+const logs = ref<ProcessLogItem[]>([])
+const people = ref<Person[]>([])
+const noteOpinion = ref('')
+const flowLoadedFor = ref('')
+
+const {
+  page: peoplePage,
+  pageSize: peoplePageSize,
+  total: peopleTotal,
+  pagedList: pagedPeople,
+  resetPage: resetPeoplePage,
+} = useClientPagination(people, { pageSize: 10 })
+
+const drawerTitle = computed(() => {
+  if (!batch.value) return '批次详情'
+  return `批次 ${batch.value.batchNo || batch.value.id}`
+})
+
+const displayRemarks = computed(() => {
+  const r = batch.value?.remarks || ''
+  if (!r) return '-'
+  return r.replace(/^MOCK_SEED_\d+\|?/, '') || '-'
+})
+
+const actionLabels = computed(() =>
+  (overview.value?.availableActions || []).map((code) => ({
+    code,
+    label: actionLabel(code),
+  })),
+)
+
+function resultType(result?: string): 'success' | 'danger' | 'primary' | 'warning' | 'info' {
+  const s = String(result || '')
+  if (s.includes('失败') || s.includes('驳回')) return 'danger'
+  if (s.includes('成功') || s.includes('通过') || s.includes('完成')) return 'success'
+  return 'primary'
+}
+
+async function loadPeopleForBatch(b: VillageBatch) {
+  peopleLoading.value = true
+  try {
+    const editable = b.stage === '人员新增录入' || b.stage === '退回修改'
+    people.value = await fetchBatchMemberPeople(b.id, {
+      batchNo: b.batchNo,
+      town: b.town,
+      village: b.village,
+      // 草稿/退回:打开详情时同步剔除暂停/停止/死亡等默认不应在册人员
+      pruneDefaults: editable,
+    })
+    resetPeoplePage()
+  } finally {
+    peopleLoading.value = false
+  }
+}
+
+async function reloadFlow(force = false) {
+  if (!props.batchId) return
+  if (!force && flowLoadedFor.value === props.batchId && logs.value.length) return
+  flowLoading.value = true
+  try {
+    let list = await fetchBatchTimeline(props.batchId)
+    if (!list.length && overview.value?.recentLogs?.length) {
+      list = overview.value.recentLogs
+    }
+    logs.value = list
+    flowLoadedFor.value = props.batchId
+  } finally {
+    flowLoading.value = false
+  }
+}
+
+async function load() {
+  if (!props.batchId) return
+  loading.value = true
+  tab.value = props.initialTab === 'flow' ? 'flow' : 'detail'
+  noteOpinion.value = ''
+  logs.value = []
+  flowLoadedFor.value = ''
+  people.value = []
+  try {
+    const [b, o] = await Promise.all([
+      fetchBatchDetail(props.batchId),
+      fetchBatchOverview(props.batchId),
+    ])
+    batch.value = b
+    overview.value = o
+    if (b) await loadPeopleForBatch(b)
+    if (tab.value === 'flow') await reloadFlow(true)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function saveNote() {
+  if (!props.batchId) return
+  if (!noteOpinion.value.trim()) {
+    ElMessage.warning('请填写备注内容')
+    return
+  }
+  noteSaving.value = true
+  try {
+    await addBatchProcessNote({
+      batchId: props.batchId,
+      opinion: noteOpinion.value.trim(),
+    })
+    ElMessage.success('备注已记录')
+    noteOpinion.value = ''
+    await reloadFlow(true)
+  } finally {
+    noteSaving.value = false
+  }
+}
+
+function onClosed() {
+  batch.value = null
+  overview.value = null
+  logs.value = []
+  people.value = []
+  noteOpinion.value = ''
+  flowLoadedFor.value = ''
+}
+
+watch(
+  () => [props.modelValue, props.batchId] as const,
+  ([open, id]) => {
+    if (open && id) void load()
+  },
+)
+
+watch(tab, (name) => {
+  if (props.modelValue && name === 'flow') void reloadFlow(false)
+})
+</script>
+
+<style scoped lang="less">
+.batch-drawer {
+  &__desc {
+    margin: 12px 0 0;
+    font-size: 12px;
+    color: @text-secondary;
+    line-height: 1.5;
+  }
+
+  &__actions {
+    margin-top: 12px;
+    padding: 12px;
+    background: #f7f2e8;
+    border: 1px solid @border-light;
+    border-radius: @radius-sm;
+  }
+
+  &__actions-title {
+    font-weight: 600;
+    color: @primary-dark;
+    margin-bottom: 8px;
+    font-size: 13px;
+  }
+
+  &__actions-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  &__tip {
+    margin: 8px 0 0;
+    font-size: 12px;
+    color: #8a7350;
+  }
+
+  &__alert {
+    margin-top: 12px;
+  }
+
+  &__sub {
+    margin: 16px 0 8px;
+    font-size: 13px;
+    font-weight: 600;
+    color: @text-primary;
+  }
+
+  &__flow-item {
+    line-height: 1.5;
+  }
+
+  &__flow-head {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 4px;
+    flex-wrap: wrap;
+  }
+
+  &__flow-action {
+    font-weight: 600;
+    color: @primary-dark;
+  }
+
+  &__flow-meta {
+    font-size: 12px;
+    color: #8a7350;
+  }
+
+  &__flow-opinion {
+    margin-top: 4px;
+    font-size: 13px;
+  }
+
+  &__note {
+    margin-top: 16px;
+    padding: 12px;
+    background: #f7f2e8;
+    border: 1px solid @border-light;
+    border-radius: @radius-sm;
+  }
+
+  &__note-btn {
+    margin-top: 10px;
+  }
+}
+</style>

+ 146 - 10
src/composables/useTownVillageOptions.ts

@@ -1,27 +1,85 @@
-import { computed, ref, type Ref } from 'vue'
+import { computed, onMounted, ref, watch, type Ref } from 'vue'
+import { getAppConfig } from '@/utils/appConfig'
 import { getTownList, getVillagesByTown } from '@/api/mock/data'
+import {
+  ensureTowns,
+  ensureVillages,
+  listTownNames,
+  listVillageNames,
+} from '@/api/sjnmtybt/regionStore'
+
+function useMock() {
+  return getAppConfig().useMock
+}
 
 /**
- * 镇 + 村联动筛选项(系统管理员全区操作、区级查询等场景)
+ * 镇 + 村联动筛选项
+ * - mock:本地演示 TOWN_META
+ * - 联调:仅 `/api/regions`,不回退写死村名
  */
 export function useTownVillageOptions(initialTown = '', initialVillage = '') {
   const filterTown = ref(initialTown)
   const filterVillage = ref(initialVillage)
+  const remoteTowns = ref<string[]>([])
+  const remoteVillages = ref<string[]>([])
+  const loadingTowns = ref(false)
+  const loadingVillages = ref(false)
 
-  const townOptions = computed(() => getTownList())
+  async function loadTowns() {
+    if (useMock()) {
+      remoteTowns.value = getTownList()
+      return
+    }
+    loadingTowns.value = true
+    try {
+      await ensureTowns()
+      remoteTowns.value = listTownNames()
+    } catch {
+      remoteTowns.value = []
+    } finally {
+      loadingTowns.value = false
+    }
+  }
+
+  async function loadVillages(town: string) {
+    if (!town) {
+      remoteVillages.value = []
+      return
+    }
+    if (useMock()) {
+      remoteVillages.value = getVillagesByTown(town)
+      return
+    }
+    loadingVillages.value = true
+    try {
+      await ensureVillages(town)
+      remoteVillages.value = listVillageNames(town)
+    } catch {
+      remoteVillages.value = []
+    } finally {
+      loadingVillages.value = false
+    }
+  }
+
+  const townOptions = computed(() =>
+    useMock() ? getTownList() : remoteTowns.value,
+  )
 
   const villageOptions = computed(() => {
     if (!filterTown.value) return [] as string[]
-    return getVillagesByTown(filterTown.value)
+    if (useMock()) return getVillagesByTown(filterTown.value)
+    return remoteVillages.value
   })
 
-  function onTownChange() {
+  async function onTownChange() {
     filterVillage.value = ''
+    await loadVillages(filterTown.value)
   }
 
   function resetTownVillage() {
     filterTown.value = ''
     filterVillage.value = ''
+    remoteVillages.value = []
   }
 
   /** 按当前筛选匹配带 town/village 的数据行 */
@@ -31,20 +89,98 @@ export function useTownVillageOptions(initialTown = '', initialVillage = '') {
     return true
   }
 
+  onMounted(async () => {
+    await loadTowns()
+    if (filterTown.value) await loadVillages(filterTown.value)
+  })
+
   return {
     filterTown,
     filterVillage,
     townOptions,
     villageOptions,
+    loadingTowns,
+    loadingVillages,
+    loadTowns,
+    loadVillages,
     onTownChange,
     resetTownVillage,
     matchTownVillage,
   }
 }
 
-/** 仅提供选项列表(无状态) */
-export function getTownVillageSelectOptions(townRef?: Ref<string>) {
-  const towns = getTownList()
-  const villages = computed(() => getVillagesByTown(townRef?.value || ''))
-  return { towns, villages }
+/**
+ * 任意街镇 Ref 驱动的动态镇/村选项(查询表单、报表等多处复用)
+ * 联调只走 /api/regions,不使用前端写死村名。
+ */
+export function useDynamicRegionOptions(townRef?: Ref<string | undefined | null>) {
+  const remoteTowns = ref<string[]>([])
+  const villageCache = ref<Record<string, string[]>>({})
+  const loadingTowns = ref(false)
+
+  async function loadTowns() {
+    if (useMock()) {
+      remoteTowns.value = getTownList()
+      return remoteTowns.value
+    }
+    loadingTowns.value = true
+    try {
+      await ensureTowns()
+      remoteTowns.value = listTownNames()
+    } catch {
+      remoteTowns.value = []
+    } finally {
+      loadingTowns.value = false
+    }
+    return remoteTowns.value
+  }
+
+  async function loadVillages(town: string) {
+    if (!town) return [] as string[]
+    if (useMock()) {
+      const list = getVillagesByTown(town)
+      villageCache.value = { ...villageCache.value, [town]: list }
+      return list
+    }
+    if (villageCache.value[town]?.length) return villageCache.value[town]
+    try {
+      await ensureVillages(town)
+      const list = listVillageNames(town)
+      villageCache.value = { ...villageCache.value, [town]: list }
+      return list
+    } catch {
+      villageCache.value = { ...villageCache.value, [town]: [] }
+      return []
+    }
+  }
+
+  const townOptions = computed(() => (useMock() ? getTownList() : remoteTowns.value))
+
+  const villageOptions = computed(() => {
+    const town = townRef?.value || ''
+    if (!town) return [] as string[]
+    if (useMock()) return getVillagesByTown(town)
+    return villageCache.value[town] || listVillageNames(town)
+  })
+
+  watch(
+    () => townRef?.value,
+    (town) => {
+      if (town) void loadVillages(town)
+    },
+  )
+
+  onMounted(async () => {
+    await loadTowns()
+    const town = townRef?.value
+    if (town) await loadVillages(town)
+  })
+
+  return {
+    townOptions,
+    villageOptions,
+    loadingTowns,
+    loadTowns,
+    loadVillages,
+  }
 }

+ 18 - 0
src/constants/batchActions.ts

@@ -0,0 +1,18 @@
+/** 流程可执行动作 code → 中文(勿直接展示 code) */
+const ACTION_LABELS: Record<string, string> = {
+  TOWN_PRECHECK_PASS: '镇经发初审通过',
+  TOWN_PRECHECK_REJECT: '镇经发初审驳回',
+  LEADER_APPROVE_PASS: '分管领导审批通过',
+  LEADER_APPROVE_REJECT: '分管领导审批驳回',
+  EXPORT_COMPARE_FILE: '生成对照出盘文件',
+  CONFIRM_RETURN: '确认回盘',
+  SUBMIT_BATCH: '提交初审',
+  TRIGGER_INSURANCE: '触发居保匹配',
+  GENERATE_PAYMENT_LIST: '生成月补贴清单',
+  ARCHIVE: '归档',
+}
+
+export function actionLabel(code?: string | null): string {
+  if (code == null || code === '') return '-'
+  return ACTION_LABELS[String(code)] || String(code)
+}

+ 5 - 0
src/constants/menus.ts

@@ -20,4 +20,9 @@ export const MENU_LIST: MenuItem[] = [
   { path: '/status', title: '补助标准管理', icon: 'EditPen' },
   { path: '/alerts', title: '监控告警', icon: 'Bell' },
   { path: '/reports', title: '数据查询统计', icon: 'DataAnalysis' },
+  /** 系统管理员运维(对齐老前端) */
+  { path: '/regions', title: '行政区划管理', icon: 'OfficeBuilding' },
+  { path: '/personnel-admin', title: '人员底数管理', icon: 'UserFilled' },
+  { path: '/admin-batches', title: '批次管理', icon: 'Tickets' },
+  { path: '/system', title: '系统管理', icon: 'Tools' },
 ]

+ 165 - 22
src/layouts/MainLayout.vue

@@ -21,9 +21,11 @@
         >
           <el-menu-item v-for="item in visibleMenus" :key="item.path" :index="item.path">
             <el-icon>
-              <component :is="item.icon" />
+              <component :is="menuIcon(item.icon)" />
             </el-icon>
-            <span>{{ item.title }}</span>
+            <template #title>
+              <span>{{ item.title }}</span>
+            </template>
           </el-menu-item>
         </el-menu>
       </el-scrollbar>
@@ -47,14 +49,11 @@
           </el-breadcrumb> -->
         </div>
         <div class="main-layout__header-right">
-          <!-- <span class="header-meta">
-            {{ roleInfo?.org || '-' }} · {{ roleInfo?.label || '-' }} · {{ userStore.currentMonth }}
-          </span> -->
           <el-select
             :model-value="userStore.currentRole"
             size="small"
-            style="width: 200px"
-            placeholder="模拟登录"
+            class="header-select header-select--role"
+            placeholder="模拟角色"
             @change="onRoleChange"
           >
             <el-option
@@ -64,14 +63,31 @@
               :value="r.key"
             />
           </el-select>
-          <!-- <el-tooltip content="通知" placement="bottom">
-            <el-badge :value="2" class="header-icon-badge">
-              <el-icon class="header-icon"><Bell /></el-icon>
-            </el-badge>
-          </el-tooltip>
-          <el-tooltip content="设置" placement="bottom">
-            <el-icon class="header-icon"><Setting /></el-icon>
-          </el-tooltip> -->
+          <!-- 暂无用户体系:按角色切换演示数据范围(街镇/村居) -->
+          <el-select
+            v-if="userStore.needTownScope"
+            :model-value="userStore.scopeTown"
+            size="small"
+            class="header-select header-select--town"
+            :placeholder="userStore.townScopeRequired ? '选择街镇' : '全区'"
+            filterable
+            :clearable="!userStore.townScopeRequired"
+            @change="onTownChange"
+          >
+            <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
+          </el-select>
+          <el-select
+            v-if="userStore.needVillageScope"
+            :model-value="userStore.scopeVillage"
+            size="small"
+            class="header-select header-select--village"
+            placeholder="选择村居"
+            filterable
+            :disabled="!userStore.scopeTown"
+            @change="onVillageChange"
+          >
+            <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
+          </el-select>
           <el-dropdown trigger="click">
             <span class="header-user">
               <el-icon><User /></el-icon>
@@ -80,7 +96,9 @@
             </span>
             <template #dropdown>
               <el-dropdown-menu>
-                <el-dropdown-item>个人中心</el-dropdown-item>
+                <el-dropdown-item disabled>
+                  {{ roleInfo?.org || roleInfo?.label || '-' }}
+                </el-dropdown-item>
                 <el-dropdown-item divided>退出登录</el-dropdown-item>
               </el-dropdown-menu>
             </template>
@@ -89,44 +107,157 @@
       </el-header>
 
       <el-main class="main-layout__main">
-        <router-view />
+        <!-- scopeKey:切换角色/镇/村时刷新当前页数据 -->
+        <router-view :key="userStore.scopeKey" />
       </el-main>
     </el-container>
   </el-container>
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, onMounted, ref, watch, type Component } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
+import * as Icons from '@element-plus/icons-vue'
 import { getAppConfig } from '@/utils/appConfig'
 import { MENU_LIST } from '@/constants/menus'
 import { useUserStore } from '@/stores/user'
+import { getTownList, getVillagesByTown } from '@/api/mock/data'
+import {
+  ensureTowns,
+  ensureVillages,
+  listTownNames,
+  listVillageNames,
+} from '@/api/sjnmtybt/regionStore'
+
+/** 菜单图标解析:避免全局注册名冲突(如 Setting)导致不渲染 */
+function menuIcon(name: string): Component {
+  const map = Icons as Record<string, Component>
+  return map[name] || map.Tools || map.Menu
+}
 
 const { systemName, systemLogoChar } = getAppConfig()
 const route = useRoute()
 const router = useRouter()
 const userStore = useUserStore()
 const isCollapse = ref(false)
+const remoteTowns = ref<string[]>([])
+const remoteVillages = ref<string[]>([])
 
 const activeMenu = computed(() => route.path)
 const pageTitle = computed(() => (route.meta.title as string) || '')
 const roleInfo = computed(() => userStore.roleInfo)
 
-// const breadcrumbs = computed(() =>
-//   route.matched.filter((item) => item.meta?.title),
-// )
+const useMock = () => getAppConfig().useMock
+
+const townOptions = computed(() => {
+  if (useMock()) return getTownList()
+  return remoteTowns.value
+})
+
+const villageOptions = computed(() => {
+  const town = userStore.scopeTown
+  if (!town) return [] as string[]
+  if (useMock()) return getVillagesByTown(town)
+  return remoteVillages.value.length ? remoteVillages.value : listVillageNames(town)
+})
 
 /** 按当前角色权限过滤菜单(权限改 config 即可) */
 const visibleMenus = computed(() =>
   MENU_LIST.filter((m) => userStore.canAccess(m.path)),
 )
 
+async function loadTownOptions() {
+  if (useMock()) {
+    remoteTowns.value = getTownList()
+    return
+  }
+  try {
+    await ensureTowns()
+    remoteTowns.value = listTownNames()
+  } catch {
+    remoteTowns.value = []
+  }
+}
+
+async function loadVillageOptions(town: string) {
+  if (!town) {
+    remoteVillages.value = []
+    return
+  }
+  if (useMock()) {
+    remoteVillages.value = getVillagesByTown(town)
+    return
+  }
+  try {
+    await ensureVillages(town)
+    remoteVillages.value = listVillageNames(town)
+  } catch {
+    remoteVillages.value = []
+  }
+}
+
+function ensureDefaultScope() {
+  // 区级不强制街镇;村/镇级无已选时补默认
+  if (
+    userStore.townScopeRequired &&
+    userStore.needTownScope &&
+    !userStore.scopeTown &&
+    townOptions.value.length
+  ) {
+    const prefer =
+      userStore.roles.find((r) => r.key === userStore.currentRole)?.town ||
+      townOptions.value[0]
+    const town =
+      prefer && townOptions.value.includes(prefer) ? prefer : townOptions.value[0]
+    userStore.setScopeTown(town)
+  }
+  const villages = villageOptions.value
+  if (userStore.needVillageScope && userStore.scopeTown && villages.length) {
+    const roleVillage = userStore.roles.find((r) => r.key === userStore.currentRole)?.village
+    const current = userStore.scopeVillage
+    // 当前选中不在区划列表中(如历史写死的「同建村」)必须纠正
+    if (!current || !villages.includes(current)) {
+      const prefer =
+        roleVillage && villages.includes(roleVillage) ? roleVillage : villages[0]
+      userStore.setScopeVillage(prefer)
+    }
+  }
+}
+
 function onRoleChange(key: string) {
   userStore.switchRole(key)
+  void loadVillageOptions(userStore.scopeTown || '').then(() => ensureDefaultScope())
   if (!userStore.canAccess(route.path)) {
     router.push(userStore.allowedPaths[0] || '/dashboard')
   }
 }
+
+function onTownChange(town: string | null) {
+  userStore.setScopeTown(town || '')
+  void loadVillageOptions(town || '').then(() => {
+    const villages = villageOptions.value
+    if (userStore.needVillageScope && villages.length) {
+      userStore.setScopeVillage(villages[0])
+    }
+  })
+}
+
+function onVillageChange(village: string) {
+  userStore.setScopeVillage(village)
+}
+
+watch(
+  () => userStore.scopeTown,
+  (town) => {
+    void loadVillageOptions(town || '')
+  },
+)
+
+onMounted(async () => {
+  await loadTownOptions()
+  if (userStore.scopeTown) await loadVillageOptions(userStore.scopeTown)
+  ensureDefaultScope()
+})
 </script>
 
 <style scoped lang="less">
@@ -261,9 +392,21 @@ function onRoleChange(key: string) {
   &__header-right {
     display: flex;
     align-items: center;
-    gap: 12px;
+    gap: 8px;
     flex-shrink: 0;
 
+    .header-select {
+      &--role {
+        width: 168px;
+      }
+      &--town {
+        width: 120px;
+      }
+      &--village {
+        width: 120px;
+      }
+    }
+
     .header-meta {
       font-size: 12px;
       color: @text-secondary;

+ 3 - 1
src/router/index.ts

@@ -31,13 +31,15 @@ router.beforeEach((to) => {
   if (to.name === 'NotFound') return true
 
   const userStore = useUserStore()
-  const path = to.path
+  const path = (to.path || '/').replace(/\/+$/, '') || '/'
 
   // 布局根路径放行
   if (path === '/') return true
 
   if (!userStore.canAccess(path)) {
+    // 无权限:回到当前角色第一个可访问页(勿误当成「强制回村工作台」)
     const fallback = userStore.allowedPaths[0] || '/dashboard'
+    if (fallback === path) return true
     return fallback
   }
   return true

+ 24 - 0
src/router/routes.ts

@@ -65,4 +65,28 @@ export const businessRoutes: RouteRecordRaw[] = [
     component: () => import('@/views/reports/ReportsView.vue'),
     meta: { title: '数据查询统计' },
   },
+  {
+    path: 'regions',
+    name: 'RegionsAdmin',
+    component: () => import('@/views/admin/RegionsAdminView.vue'),
+    meta: { title: '行政区划管理' },
+  },
+  {
+    path: 'personnel-admin',
+    name: 'PersonnelAdmin',
+    component: () => import('@/views/admin/PersonnelAdminView.vue'),
+    meta: { title: '人员底数管理' },
+  },
+  {
+    path: 'admin-batches',
+    name: 'AdminBatches',
+    component: () => import('@/views/admin/AdminBatchesView.vue'),
+    meta: { title: '批次管理' },
+  },
+  {
+    path: 'system',
+    name: 'SystemAdmin',
+    component: () => import('@/views/system/SystemAdminView.vue'),
+    meta: { title: '系统管理' },
+  },
 ]

+ 171 - 10
src/stores/user.ts

@@ -2,23 +2,89 @@ import { defineStore } from 'pinia'
 import { computed, ref } from 'vue'
 import { getAppConfig, type RoleItem } from '@/utils/appConfig'
 
+const ROLE_STORAGE_KEY = 'sj_tdtybz_current_role'
+const MONTH_STORAGE_KEY = 'sj_tdtybz_current_month'
+const TOWN_STORAGE_KEY = 'sj_tdtybz_scope_town'
+const VILLAGE_STORAGE_KEY = 'sj_tdtybz_scope_village'
+
+function readStored(key: string, fallback = '') {
+  try {
+    return localStorage.getItem(key) || fallback
+  } catch {
+    return fallback
+  }
+}
+
+function writeStored(key: string, value: string) {
+  try {
+    if (value) localStorage.setItem(key, value)
+    else localStorage.removeItem(key)
+  } catch {
+    /* ignore */
+  }
+}
+
+function readStoredRole(fallback: string) {
+  const saved = readStored(ROLE_STORAGE_KEY)
+  if (saved && getAppConfig().roles.some((r) => r.key === saved)) return saved
+  return fallback
+}
+
 /**
- * 用户/权限状态
- * - 当前角色来自 config(演示)或后续登录接口
- * - 菜单可见性由 rolePermissions 驱动,改 config 即可
+ * 用户/权限状态(暂无真实登录)
+ * - 角色 + 数据范围(街镇/村居)可切换,写入 localStorage
+ * - 菜单可见性由 rolePermissions 驱动
  */
 export const useUserStore = defineStore('user', () => {
   const config = getAppConfig()
 
-  const currentRole = ref(config.currentRole)
-  const currentMonth = ref(config.currentMonth)
+  const currentRole = ref(readStoredRole(config.currentRole))
+  const currentMonth = ref(readStored(MONTH_STORAGE_KEY, config.currentMonth))
+  const scopeTown = ref(readStored(TOWN_STORAGE_KEY, ''))
+  const scopeVillage = ref(readStored(VILLAGE_STORAGE_KEY, ''))
 
   const roles = computed(() => getAppConfig().roles)
 
-  const roleInfo = computed<RoleItem | undefined>(() =>
+  const baseRole = computed<RoleItem | undefined>(() =>
     roles.value.find((r) => r.key === currentRole.value),
   )
 
+  /** 是否展示街镇下拉:村/镇级必选;区级角色可选(空=全区) */
+  const needTownScope = computed(() => {
+    const r = baseRole.value
+    if (!r) return false
+    if (r.allData) return true
+    return (
+      r.key === 'village' ||
+      ['town', 'social', 'leader'].includes(r.key) ||
+      Boolean(r.town)
+    )
+  })
+
+  /** 是否展示村居下拉:仅村合作社 */
+  const needVillageScope = computed(() => baseRole.value?.key === 'village')
+
+  /** 街镇是否必选(区级可清空表示全区) */
+  const townScopeRequired = computed(() => !baseRole.value?.allData)
+
+  /** 对外角色信息:合并演示用街镇/村居范围 */
+  const roleInfo = computed<RoleItem | undefined>(() => {
+    const r = baseRole.value
+    if (!r) return undefined
+    const town = r.allData
+      ? scopeTown.value || ''
+      : scopeTown.value || r.town || ''
+    const village = needVillageScope.value
+      ? scopeVillage.value || r.village || ''
+      : ''
+    return {
+      ...r,
+      town: town || undefined,
+      village: village || undefined,
+      org: buildOrgLabel(r, town, village),
+    }
+  })
+
   const allowedPaths = computed(() => {
     const map = getAppConfig().rolePermissions
     return map[currentRole.value] || ['/dashboard']
@@ -29,32 +95,127 @@ export const useUserStore = defineStore('user', () => {
     return list.includes(currentRole.value)
   })
 
+  /** 供页面强制刷新:角色或数据范围变化 */
+  const scopeKey = computed(
+    () =>
+      `${currentRole.value}|${roleInfo.value?.town || ''}|${roleInfo.value?.village || ''}|${currentMonth.value}`,
+  )
+
+  function syncAppConfig() {
+    if (!window.AppConfig) return
+    window.AppConfig.currentRole = currentRole.value
+    window.AppConfig.currentMonth = currentMonth.value
+    const list = window.AppConfig.roles
+    if (!Array.isArray(list)) return
+    const idx = list.findIndex((r) => r.key === currentRole.value)
+    if (idx < 0) return
+    const base = list[idx]
+    const town = base.allData
+      ? scopeTown.value || undefined
+      : scopeTown.value || base.town
+    const village = needVillageScope.value
+      ? scopeVillage.value || base.village
+      : undefined
+    list[idx] = {
+      ...base,
+      town,
+      village,
+      org: buildOrgLabel(base, town || '', village || ''),
+    }
+  }
+
   function canAccess(path: string) {
-    return allowedPaths.value.includes(path)
+    const normalized = path.replace(/\/+$/, '') || '/'
+    return allowedPaths.value.includes(normalized)
   }
 
   function switchRole(key: string) {
     if (!roles.value.some((r) => r.key === key)) return
     currentRole.value = key
-    // 演示:同步写回,便于刷新保持;正式环境由登录态接管
-    if (window.AppConfig) {
-      window.AppConfig.currentRole = key
+    writeStored(ROLE_STORAGE_KEY, key)
+    const r = roles.value.find((x) => x.key === key)
+    if (r?.allData) {
+      // 区级:街镇可选,不强制;清村
+      scopeVillage.value = ''
+      writeStored(VILLAGE_STORAGE_KEY, '')
+    } else {
+      // 村/镇级:无已选时用角色默认街镇
+      if (!scopeTown.value && r?.town) {
+        scopeTown.value = r.town
+        writeStored(TOWN_STORAGE_KEY, r.town)
+      }
+      if (r?.key === 'village') {
+        // 默认村名最终以区划接口列表校验(布局 ensureDefaultScope);此处仅补空值
+        if (!scopeVillage.value && r.village) {
+          scopeVillage.value = r.village
+          writeStored(VILLAGE_STORAGE_KEY, r.village)
+        }
+      } else {
+        scopeVillage.value = ''
+        writeStored(VILLAGE_STORAGE_KEY, '')
+      }
+    }
+    syncAppConfig()
+  }
+
+  function setScopeTown(town: string) {
+    scopeTown.value = town || ''
+    writeStored(TOWN_STORAGE_KEY, scopeTown.value)
+    // 换镇后清空村,由界面重选
+    if (needVillageScope.value) {
+      scopeVillage.value = ''
+      writeStored(VILLAGE_STORAGE_KEY, '')
     }
+    syncAppConfig()
+  }
+
+  function setScopeVillage(village: string) {
+    scopeVillage.value = village || ''
+    writeStored(VILLAGE_STORAGE_KEY, scopeVillage.value)
+    syncAppConfig()
   }
 
   function setMonth(month: string) {
     currentMonth.value = month
+    writeStored(MONTH_STORAGE_KEY, month)
+    syncAppConfig()
   }
 
+  // 初始化:补默认街镇/村居
+  if (!scopeTown.value && baseRole.value?.town) {
+    scopeTown.value = baseRole.value.town
+  }
+  if (!scopeVillage.value && baseRole.value?.village) {
+    scopeVillage.value = baseRole.value.village
+  }
+  syncAppConfig()
+
   return {
     currentRole,
     currentMonth,
+    scopeTown,
+    scopeVillage,
+    needTownScope,
+    needVillageScope,
+    townScopeRequired,
+    scopeKey,
     roles,
     roleInfo,
     allowedPaths,
     alertReadonly,
     canAccess,
     switchRole,
+    setScopeTown,
+    setScopeVillage,
     setMonth,
   }
 })
+
+function buildOrgLabel(role: RoleItem, town: string, village: string) {
+  if (role.key === 'village' && town && village) return `${town}${village}合作社`
+  if ((role.key === 'town' || role.key === 'social' || role.key === 'leader') && town) {
+    if (role.key === 'town') return `${town}经济发展中心`
+    return `${town}社区事务受理服务中心`
+  }
+  return role.org
+}

+ 56 - 4
src/types/biz.ts

@@ -1,7 +1,8 @@
 /** 业务相关类型(后续对接接口时字段可按后端微调) */
 
 export interface Person {
-  id: number
+  /** 联调后端为人员业务 ID(字符串);mock 为数字 */
+  id: number | string
   batchNo: string
   name: string
   idCard: string
@@ -11,7 +12,8 @@ export interface Person {
   bankName: string
   /** 社会保险登记码 */
   socialInsuranceCode?: string
-  standard: number
+  /** 街镇当前有效月补标准;该镇无标准时为 undefined,勿展示陈旧值 */
+  standard?: number
   startMonth: string
   status: string
   source: string
@@ -36,7 +38,7 @@ export interface TownVillageReviewItem {
 
 /** 镇经发中心:待初审新增人员简要信息 */
 export interface TownPendingPersonItem {
-  id: number
+  id: number | string
   name: string
   idCard: string
   village: string
@@ -78,6 +80,52 @@ export interface VillageBatch {
   stage: string
   submitter: string
   submittedAt?: string
+  /** 后端原始状态码(联调) */
+  statusCode?: string
+  /** 出盘状态 */
+  exportStatus?: string
+  /** 回盘状态 */
+  returnStatus?: string
+  /** 出盘编号 */
+  exportDiskNo?: string
+  /** 出盘文件名 */
+  exportFileName?: string
+  /** 镇审批意见 */
+  townOpinion?: string
+  /** 备注 */
+  remarks?: string
+  normalAmount?: number
+  supplementAmount?: number
+  deductAmount?: number
+}
+
+/** 批次流程时间线条目 */
+export interface ProcessLogItem {
+  id: string
+  batchId?: string
+  batchNo?: string
+  stage?: string
+  action?: string
+  approveNode?: string
+  operator?: string
+  operatorRole?: string
+  operatorOrg?: string
+  opinion?: string
+  result?: string
+  status?: string
+  operateTime?: string
+  fileUrls?: string
+}
+
+/** 批次流程总览 */
+export interface BatchFlowOverview {
+  batchId: string
+  batchNo: string
+  currentStage?: string
+  status?: string
+  stageDescription?: string
+  availableActions: string[]
+  recentLogs: ProcessLogItem[]
 }
 
 export interface MonthlyBatch {
@@ -118,6 +166,8 @@ export interface VillageMonthlyBatch {
 
 export interface Approval {
   id: string
+  /** 关联村批 ID(月度批次审批有值;特殊业务可能为空) */
+  batchId?: string
   bizType: string
   batchNo: string
   town: string
@@ -128,6 +178,8 @@ export interface Approval {
   status: string
   opinion?: string
   createdAt: string
+  /** 批次申报人数(来自批次头,联调展示用) */
+  peopleCount?: number
 }
 
 export interface SpecialBiz {
@@ -148,7 +200,7 @@ export interface SpecialBiz {
 export interface AdjustReissuePerson {
   id: string
   batchNo: string
-  personId: number
+  personId: number | string
   name: string
   idCard: string
   town: string

+ 7 - 4
src/utils/appConfig.ts

@@ -17,6 +17,8 @@ export interface AppConfig {
   systemTitle: string
   systemSubtitle: string
   requestTimeout: number
+  /** 业务 API 前缀,开发默认 /api(Vite 代理到 sjnmtybt-server) */
+  apiBase: string
   /** 是否使用本地 mock 数据 */
   useMock: boolean
   /** 当前模拟角色 key */
@@ -43,17 +45,18 @@ const fallbackConfig: AppConfig = {
   systemTitle: '农民土地退养补助金',
   systemSubtitle: '发放管理平台',
   requestTimeout: 15000,
-  useMock: true,
+  apiBase: '/api',
+  useMock: false,
   currentRole: 'admin',
-  currentMonth: '2025-10',
+  currentMonth: '2026-08',
   roles: [
     {
       key: 'village',
       label: '村合作社',
-      org: '叶榭镇同建村合作社',
+      org: '叶榭镇叶榭村合作社',
       allData: false,
       town: '叶榭镇',
-      village: '同建村',
+      village: '叶榭村',
     },
     {
       key: 'town',

+ 3 - 2
src/utils/mask.ts

@@ -9,6 +9,7 @@ export function maskBankCard(card: string): string {
   return `${card.slice(0, 4)} **** **** ${card.slice(-4)}`
 }
 
-export function formatAmount(n: number): string {
-  return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+export function formatAmount(n?: number | null): string {
+  if (n == null || Number.isNaN(Number(n))) return '—'
+  return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
 }

+ 15 - 2
src/utils/request.ts

@@ -16,17 +16,30 @@ export interface ApiResponse<T = unknown> {
 }
 
 const service: AxiosInstance = axios.create({
-  baseURL: '/api',
+  // 联调默认走 Vite 代理 /api → sjnmtybt-server;亦可在 config.js 配 apiBase
+  baseURL: getAppConfig().apiBase || '/api',
   timeout: getAppConfig().requestTimeout || 15000,
 })
 
 // 请求拦截
 service.interceptors.request.use(
   (config: InternalAxiosRequestConfig) => {
-    const token = localStorage.getItem('token')
+    const token =
+      localStorage.getItem('sjnmtybt_token') ||
+      localStorage.getItem('token') ||
+      ''
     if (token) {
+      // 对齐后端鉴权:Token / token / Bearer 均可
+      config.headers.Token = token
+      config.headers.token = token
       config.headers.Authorization = `Bearer ${token}`
     }
+    // FormData 交给浏览器设置 multipart boundary
+    if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
+      if (config.headers) {
+        delete (config.headers as Record<string, unknown>)['Content-Type']
+      }
+    }
     return config
   },
   (error) => Promise.reject(error),

+ 177 - 0
src/views/admin/AdminBatchesView.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="admin-batches-view">
+    <PagePanel title="批次管理">
+      <p class="tip">
+        全区批次运维查询。「已归档」多为重复/作废残留。删除会摘离明细并从列表隐藏。日常业务仍由各岗位办理。
+      </p>
+      <div class="query-panel">
+        <el-form :inline="true" size="small">
+          <el-form-item label="发放年月">
+            <el-input v-model="q.payMonth" clearable placeholder="yyyyMM" style="width: 120px" />
+          </el-form-item>
+          <el-form-item label="街镇">
+            <el-select v-model="q.townId" clearable style="width: 140px" @change="onTown">
+              <el-option v-for="t in towns" :key="t.id" :label="t.name" :value="t.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="村居">
+            <el-select v-model="q.villageId" clearable style="width: 140px">
+              <el-option v-for="v in villages" :key="v.id" :label="v.name" :value="v.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="级别">
+            <el-select v-model="q.batchLevel" clearable style="width: 110px">
+              <el-option label="村级" value="VILLAGE" />
+              <el-option label="镇级" value="TOWN" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="search">查询</el-button>
+            <el-button @click="reset">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+        <el-table-column prop="id" label="批次ID" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="payMonth" label="发放年月" width="100" align="center" />
+        <el-table-column label="级别" width="70" align="center">
+          <template #default="{ row }">{{ row.batchLevel === 'TOWN' ? '镇' : '村' }}</template>
+        </el-table-column>
+        <el-table-column prop="town" label="街镇" width="100" />
+        <el-table-column prop="village" label="村居" width="100" />
+        <el-table-column prop="peopleCount" label="人数" width="70" align="center" />
+        <el-table-column label="合计" width="110" align="right">
+          <template #default="{ row }">{{ formatAmt(row.totalAmount) }}</template>
+        </el-table-column>
+        <el-table-column prop="status" label="状态" width="140" show-overflow-tooltip />
+        <el-table-column prop="currentStage" label="阶段" width="150" show-overflow-tooltip />
+        <el-table-column label="操作" width="200" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="open(row, 'detail')">详情</el-button>
+            <el-button link type="primary" size="small" @click="open(row, 'flow')">流程</el-button>
+            <el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
+    </PagePanel>
+
+    <BatchDetailDrawer
+      v-model="drawer"
+      :batch-id="drawerId"
+      :initial-tab="drawerTab"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import PagePanel from '@/components/common/PagePanel.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
+import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import { listRegionsAdmin, pageAdminBatches, purgeBatchAdmin } from '@/api/biz'
+import { useClientPagination } from '@/composables/useClientPagination'
+import type { RegionRow } from '@/api/sjnmtybt/regionStore'
+
+type AdminBatch = {
+  id: string
+  payMonth: string
+  batchLevel: string
+  town: string
+  village: string
+  peopleCount: number
+  totalAmount: number
+  status: string
+  currentStage: string
+}
+
+const q = reactive({
+  payMonth: '',
+  townId: '',
+  villageId: '',
+  batchLevel: '',
+})
+const list = ref<AdminBatch[]>([])
+const towns = ref<RegionRow[]>([])
+const villages = ref<RegionRow[]>([])
+const loading = ref(false)
+const drawer = ref(false)
+const drawerId = ref('')
+const drawerTab = ref<'detail' | 'flow'>('detail')
+
+const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
+
+function formatAmt(n: number) {
+  return Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
+async function onTown() {
+  q.villageId = ''
+  villages.value = q.townId
+    ? await listRegionsAdmin({ level: 'VILLAGE', parentId: q.townId })
+    : []
+}
+
+async function search() {
+  loading.value = true
+  try {
+    list.value = (await pageAdminBatches({
+      payMonth: q.payMonth || undefined,
+      townId: q.townId || undefined,
+      villageId: q.villageId || undefined,
+      batchLevel: q.batchLevel || undefined,
+    })) as AdminBatch[]
+    resetPage()
+  } finally {
+    loading.value = false
+  }
+}
+
+function reset() {
+  q.payMonth = ''
+  q.townId = ''
+  q.villageId = ''
+  q.batchLevel = ''
+  villages.value = []
+  search()
+}
+
+function open(row: AdminBatch, tab: 'detail' | 'flow') {
+  drawerId.value = row.id
+  drawerTab.value = tab
+  drawer.value = true
+}
+
+async function onDelete(row: AdminBatch) {
+  try {
+    await ElMessageBox.confirm(
+      `确认删除批次 ${row.id}?将软摘离明细并隐藏该批次。`,
+      '删除批次',
+      { type: 'warning', confirmButtonText: '确认删除' },
+    )
+  } catch {
+    return
+  }
+  await purgeBatchAdmin(row.id)
+  ElMessage.success('批次已删除')
+  await search()
+}
+
+onMounted(async () => {
+  towns.value = await listRegionsAdmin({ level: 'TOWN', parentId: 'D_SJ' })
+  await search()
+})
+</script>
+
+<style scoped lang="less">
+.tip {
+  margin: 0 0 10px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
+.query-panel {
+  margin-bottom: 10px;
+}
+</style>

+ 208 - 0
src/views/admin/PersonnelAdminView.vue

@@ -0,0 +1,208 @@
+<template>
+  <div class="personnel-admin-view">
+    <PagePanel title="人员底数管理">
+      <p class="tip">
+        全区人员底数查询与运维。可变更业务状态、删除(归档)。日常新增录入仍建议在「人员新增录入」按村办理。
+      </p>
+      <div class="query-panel">
+        <el-form :inline="true" size="small">
+          <el-form-item label="街镇">
+            <el-select v-model="filterTown" clearable style="width: 140px" @change="onTownChange">
+              <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="村居">
+            <el-select v-model="filterVillage" clearable style="width: 140px">
+              <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="姓名">
+            <el-input v-model="keyword" clearable placeholder="模糊" style="width: 120px" />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="search">查询</el-button>
+            <el-button @click="reset">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+        <el-table-column prop="town" label="街镇" width="90" />
+        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column prop="name" label="姓名" width="88" />
+        <el-table-column label="身份证号" min-width="160">
+          <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+        </el-table-column>
+        <el-table-column label="月补" width="100" align="right">
+          <template #default="{ row }">
+            {{ row.standard != null && Number(row.standard) > 0 ? row.standard : '—' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="业务状态" width="110" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column label="居保匹配" width="100" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="row.insuranceStatus || '未匹配'" />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="warning" size="small" @click="openStatus(row)">变更状态</el-button>
+            <el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
+    </PagePanel>
+
+    <el-dialog v-model="statusDlg" title="变更人员状态" width="420px" destroy-on-close>
+      <el-form label-width="96px" size="small">
+        <el-form-item label="人员">{{ current?.name }}</el-form-item>
+        <el-form-item label="业务状态" required>
+          <el-select v-model="statusForm.bizStatus" style="width: 100%">
+            <el-option label="草稿" value="DRAFT" />
+            <el-option label="待居保匹配" value="PENDING_INSURANCE_MATCH" />
+            <el-option label="正常在库" value="NORMAL" />
+            <el-option label="暂停" value="PAUSED" />
+            <el-option label="停止" value="STOPPED" />
+            <el-option label="死亡丧葬" value="FUNERAL" />
+            <el-option label="异常待处理" value="ABNORMAL" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="本月发放">
+          <el-switch v-model="statusForm.payThisMonth" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="statusForm.remarks" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="statusDlg = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveStatus">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import PagePanel from '@/components/common/PagePanel.vue'
+import StatusTag from '@/components/common/StatusTag.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
+import {
+  deletePersonnelAdmin,
+  fetchPeople,
+  updatePersonnelStatusAdmin,
+} from '@/api/biz'
+import { useClientPagination } from '@/composables/useClientPagination'
+import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
+import { maskIdCard } from '@/utils/mask'
+import type { Person } from '@/types/biz'
+
+const {
+  filterTown,
+  filterVillage,
+  townOptions,
+  villageOptions,
+  onTownChange,
+  resetTownVillage,
+} = useTownVillageOptions()
+
+const keyword = ref('')
+const list = ref<Person[]>([])
+const loading = ref(false)
+const saving = ref(false)
+const statusDlg = ref(false)
+const current = ref<Person | null>(null)
+const statusForm = ref({
+  bizStatus: 'NORMAL',
+  payThisMonth: true,
+  remarks: '',
+})
+
+const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
+
+async function search() {
+  loading.value = true
+  try {
+    let rows = await fetchPeople({
+      town: filterTown.value || undefined,
+      village: filterVillage.value || undefined,
+    })
+    const kw = keyword.value.trim()
+    if (kw) {
+      rows = rows.filter((p) => p.name?.includes(kw) || p.idCard?.includes(kw))
+    }
+    list.value = rows
+    resetPage()
+  } finally {
+    loading.value = false
+  }
+}
+
+function reset() {
+  resetTownVillage()
+  keyword.value = ''
+  search()
+}
+
+function openStatus(row: Person) {
+  current.value = row
+  statusForm.value = {
+    bizStatus: 'NORMAL',
+    payThisMonth: true,
+    remarks: '',
+  }
+  statusDlg.value = true
+}
+
+async function saveStatus() {
+  if (!current.value) return
+  saving.value = true
+  try {
+    await updatePersonnelStatusAdmin({
+      personnelId: String(current.value.id),
+      bizStatus: statusForm.value.bizStatus,
+      payThisMonth: statusForm.value.payThisMonth,
+      remarks: statusForm.value.remarks || undefined,
+    })
+    ElMessage.success('状态已更新')
+    statusDlg.value = false
+    await search()
+  } finally {
+    saving.value = false
+  }
+}
+
+async function onDelete(row: Person) {
+  try {
+    await ElMessageBox.confirm(
+      `确认删除人员「${row.name}」?将归档并从未闭环批次明细中摘离。`,
+      '删除人员',
+      { type: 'warning', confirmButtonText: '确认删除' },
+    )
+  } catch {
+    return
+  }
+  await deletePersonnelAdmin(String(row.id))
+  ElMessage.success('人员已删除')
+  await search()
+}
+
+onMounted(search)
+</script>
+
+<style scoped lang="less">
+.tip {
+  margin: 0 0 10px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
+.query-panel {
+  margin-bottom: 10px;
+}
+</style>

+ 251 - 0
src/views/admin/RegionsAdminView.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="regions-admin-view">
+    <PagePanel title="行政区划管理">
+      <p class="tip">
+        维护区/街镇/村居。人员、批次、金额标准均依赖区划;代码(code)建议稳定不改,名称可改。
+      </p>
+      <div class="query-panel">
+        <el-form :inline="true" size="small">
+          <el-form-item label="层级">
+            <el-select v-model="q.level" style="width: 120px" @change="onLevel">
+              <el-option label="区" value="DISTRICT" />
+              <el-option label="街镇" value="TOWN" />
+              <el-option label="村居" value="VILLAGE" />
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="q.level === 'VILLAGE'" label="上级街镇">
+            <el-select v-model="q.parentId" clearable style="width: 160px">
+              <el-option v-for="t in towns" :key="t.id" :label="t.name" :value="t.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="search">查询</el-button>
+            <el-button @click="openCreate">新增区划</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+        <el-table-column prop="code" label="代码" min-width="120" />
+        <el-table-column prop="name" label="名称" min-width="140" />
+        <el-table-column label="层级" width="88">
+          <template #default="{ row }">{{ levelLabel(row.level) }}</template>
+        </el-table-column>
+        <el-table-column label="上级" min-width="120">
+          <template #default="{ row }">{{ parentLabel(row.parentId) }}</template>
+        </el-table-column>
+        <el-table-column prop="entrustPlanCode" label="委托方案代码" min-width="120" />
+        <el-table-column label="启用" width="70" align="center">
+          <template #default="{ row }">{{ row.enabled === false ? '否' : '是' }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="140" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
+            <el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
+    </PagePanel>
+
+    <el-dialog v-model="dlg" :title="form.id ? '编辑区划' : '新增区划'" width="520px" destroy-on-close>
+      <el-form label-width="110px" size="small">
+        <el-form-item label="层级" required>
+          <el-select v-model="form.level" style="width: 100%" :disabled="!!form.id">
+            <el-option label="区" value="DISTRICT" />
+            <el-option label="街镇" value="TOWN" />
+            <el-option label="村居" value="VILLAGE" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="区划代码" required>
+          <el-input v-model="form.code" :disabled="!!form.id" placeholder="如 T_YEXIE / V_YX_001" />
+        </el-form-item>
+        <el-form-item label="名称" required>
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item v-if="form.level !== 'DISTRICT'" label="上级区划" required>
+          <el-select v-model="form.parentId" style="width: 100%">
+            <el-option v-if="form.level === 'TOWN'" label="松江区" value="D_SJ" />
+            <el-option v-for="t in towns" v-else :key="t.id" :label="t.name" :value="t.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="委托方案代码">
+          <el-input v-model="form.entrustPlanCode" />
+        </el-form-item>
+        <el-form-item label="默认银行">
+          <el-input v-model="form.defaultBank" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remarks" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dlg = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="save">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import PagePanel from '@/components/common/PagePanel.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
+import {
+  deleteRegionAdmin,
+  listRegionsAdmin,
+  saveRegionAdmin,
+} from '@/api/biz'
+import { useClientPagination } from '@/composables/useClientPagination'
+import type { RegionRow } from '@/api/sjnmtybt/regionStore'
+
+const q = reactive({ level: 'TOWN', parentId: '' })
+const list = ref<RegionRow[]>([])
+const towns = ref<RegionRow[]>([])
+const loading = ref(false)
+const saving = ref(false)
+const dlg = ref(false)
+const form = reactive({
+  id: '',
+  code: '',
+  name: '',
+  level: 'TOWN',
+  parentId: 'D_SJ',
+  entrustPlanCode: '',
+  defaultBank: '',
+  enabled: true,
+  remarks: '',
+})
+
+const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
+
+function levelLabel(lv?: string) {
+  return ({ DISTRICT: '区', TOWN: '街镇', VILLAGE: '村居' } as Record<string, string>)[lv || ''] || lv || '-'
+}
+
+function parentLabel(parentId?: string) {
+  if (!parentId) return '-'
+  if (parentId === 'D_SJ') return '松江区'
+  return towns.value.find((x) => x.id === parentId)?.name || parentId
+}
+
+async function loadTowns() {
+  towns.value = await listRegionsAdmin({ level: 'TOWN', parentId: 'D_SJ' })
+}
+
+async function search() {
+  loading.value = true
+  try {
+    const params: { level: string; parentId?: string } = { level: q.level }
+    if (q.level === 'VILLAGE' && q.parentId) params.parentId = q.parentId
+    if (q.level === 'TOWN') params.parentId = 'D_SJ'
+    list.value = await listRegionsAdmin(params)
+    resetPage()
+  } finally {
+    loading.value = false
+  }
+}
+
+function onLevel() {
+  if (q.level !== 'VILLAGE') q.parentId = ''
+  search()
+}
+
+function resetForm() {
+  Object.assign(form, {
+    id: '',
+    code: '',
+    name: '',
+    level: q.level || 'TOWN',
+    parentId: q.level === 'VILLAGE' ? q.parentId || 'T_YEXIE' : q.level === 'TOWN' ? 'D_SJ' : '',
+    entrustPlanCode: '',
+    defaultBank: '',
+    enabled: true,
+    remarks: '',
+  })
+}
+
+function openCreate() {
+  resetForm()
+  dlg.value = true
+}
+
+function openEdit(row: RegionRow & { entrustPlanCode?: string; defaultBank?: string; enabled?: boolean; remarks?: string }) {
+  Object.assign(form, {
+    id: row.id || '',
+    code: row.code || row.id || '',
+    name: row.name || '',
+    level: row.level || 'TOWN',
+    parentId: row.parentId || '',
+    entrustPlanCode: row.entrustPlanCode || '',
+    defaultBank: row.defaultBank || '',
+    enabled: row.enabled !== false,
+    remarks: row.remarks || '',
+  })
+  dlg.value = true
+}
+
+async function save() {
+  if (!form.code.trim() || !form.name.trim()) {
+    ElMessage.warning('请填写代码与名称')
+    return
+  }
+  if (form.level !== 'DISTRICT' && !form.parentId) {
+    ElMessage.warning('请选择上级区划')
+    return
+  }
+  saving.value = true
+  try {
+    await saveRegionAdmin({
+      id: form.id || undefined,
+      code: form.code.trim(),
+      name: form.name.trim(),
+      level: form.level,
+      parentId: form.level === 'DISTRICT' ? '0' : form.parentId,
+      entrustPlanCode: form.entrustPlanCode || undefined,
+      defaultBank: form.defaultBank || undefined,
+      enabled: form.enabled,
+      remarks: form.remarks || undefined,
+    })
+    ElMessage.success('已保存')
+    dlg.value = false
+    await loadTowns()
+    await search()
+  } finally {
+    saving.value = false
+  }
+}
+
+async function onDelete(row: RegionRow) {
+  try {
+    await ElMessageBox.confirm(`确认删除区划「${row.name}」?若已被引用可能失败。`, '删除确认', {
+      type: 'warning',
+    })
+  } catch {
+    return
+  }
+  await deleteRegionAdmin(row.id)
+  ElMessage.success('已删除')
+  await search()
+}
+
+onMounted(async () => {
+  await loadTowns()
+  await search()
+})
+</script>
+
+<style scoped lang="less">
+.tip {
+  margin: 0 0 10px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
+.query-panel {
+  margin-bottom: 10px;
+}
+</style>

+ 10 - 5
src/views/alerts/AlertsView.vue

@@ -27,7 +27,7 @@
         </el-form>
       </div>
       <el-table v-loading="loading" :data="pagedList" border stripe size="small">
-        <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
+        <el-table-column prop="batchNo" label="批次号" min-width="120" align="center" show-overflow-tooltip />
         <el-table-column prop="personName" label="人员" width="90" />
         <el-table-column prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="90" />
@@ -234,9 +234,11 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import { fetchAlerts, fetchPeople, handleAlert } from '@/api/biz'
-import { getVillagesByTown } from '@/api/mock/data'
 import { useClientPagination } from '@/composables/useClientPagination'
-import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
+import {
+  useDynamicRegionOptions,
+  useTownVillageOptions,
+} from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import {
@@ -299,10 +301,13 @@ const form = reactive({
   heirContact: '',
 })
 
-const formVillageOptions = computed(() => getVillagesByTown(form.town))
+const formTownRef = computed(() => form.town)
+const { villageOptions: formVillageOptions, loadVillages: loadFormVillages } =
+  useDynamicRegionOptions(formTownRef)
 
-function onFormTownChange() {
+async function onFormTownChange() {
   form.village = ''
+  if (form.town) await loadFormVillages(form.town)
 }
 
 const formRules: FormRules = {

+ 131 - 27
src/views/approvals/ApprovalsView.vue

@@ -14,21 +14,17 @@
         </el-button>
       </template>
 
-      <!-- <p class="approvals-view__tip">
+      <p class="approvals-view__tip">
         <template v-if="isTownReview">
-          待审批展示本镇当前月度(批次 {{ currentBatchNo }})各村合作社已提交的新增人员名单。
-          请在详情中逐人核验是否具备土地退养资格(含无土地但有资格情形),核验完成后再整批通过或驳回。
+          待审批为本镇当前月(批次 {{ currentBatchNo }})各村已提交名单。请先在「详情」中完成资格核验,再整批通过或驳回;人数以本批发放明细为准。
         </template>
         <template v-else-if="isLeaderReview">
-          您为镇社区事务受理服务中心上级领导,主要审批其提交的本镇批次。
-          月补贴清单:通过后由社区事务中心出盘;驳回退回居保匹配。
-          特殊业务:驳回退回业务受理补充/修改。
+          审批社区事务中心提交的月补贴清单与特殊业务。月补贴通过后可出盘;驳回退回居保匹配。特殊业务驳回退回业务补充。
         </template>
         <template v-else>
-          月度批次:镇经发中心初审(通过后进入居保状态匹配,驳回即退回村合作社补充/修改);分管领导线上审批(驳回退回居保匹配)。
-          特殊业务:分管领导审批(驳回退回业务受理补充/修改)。
+          镇经发初审通过后进入居保匹配;分管领导审月补贴/特殊业务。驳回分别退回村合作社或居保节点。
         </template>
-      </p> -->
+      </p>
 
       <el-tabs v-model="activeTab" @tab-change="loadApprovals">
         <el-tab-pane :label="isTownReview ? '待审批(本月提交名单)' : '待审批'" name="pending" />
@@ -74,6 +70,11 @@
         <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
         <el-table-column v-if="!isTownReview || isAdmin" prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column label="人数" width="64" align="center">
+          <template #default="{ row }">
+            {{ (row as Approval).peopleCount != null ? (row as Approval).peopleCount : '—' }}
+          </template>
+        </el-table-column>
         <el-table-column prop="applicant" label="申请人" width="140" show-overflow-tooltip />
         <el-table-column prop="node" label="审批节点" width="140" />
         <el-table-column label="状态" width="88" align="center">
@@ -81,17 +82,30 @@
             <StatusTag :status="row.status" />
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="提交时间" width="140" />
-        <el-table-column label="操作" width="180" align="center" fixed="right">
+        <el-table-column label="提交时间" width="160">
+          <template #default="{ row }">
+            {{ (row as Approval).createdAt || '—' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="260" align="center" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" size="small" @click="openDetail(row as Approval)">
               详情
             </el-button>
             <el-button
-              v-if="row.status === '待审批'"
+              v-if="batchIdOfApproval(row as Approval)"
               link
               type="primary"
               size="small"
+              @click="openBatchFlow(row as Approval)"
+            >
+              流程记录
+            </el-button>
+            <el-button
+              v-if="row.status === '待审批'"
+              link
+              type="success"
+              size="small"
               @click="onDecide(row as Approval, true)"
             >
               通过
@@ -111,6 +125,12 @@
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
+    <BatchDetailDrawer
+      v-model="batchDrawerVisible"
+      :batch-id="batchDrawerId"
+      initial-tab="flow"
+    />
+
     <!-- 审批详情:含本村人员列表 -->
     <el-dialog
       v-model="detailVisible"
@@ -137,15 +157,34 @@
       </el-descriptions>
 
       <div class="approvals-view__people">
-        <div class="approvals-view__people-title">
-          {{ isTownReview ? '村合作社提交名单' : '本村人员列表' }}
-          <span v-if="currentRow?.village">
-            ({{ currentRow.town }}{{ currentRow.village }} · 批次 {{ currentRow.batchNo }})
-          </span>
-          <em>共 {{ detailPeople.length }} 人</em>
-          <em v-if="isTownReview && pendingQualifyCount > 0" class="approvals-view__people-warn">
-            待核验 {{ pendingQualifyCount }} 人
-          </em>
+        <div class="approvals-view__people-toolbar">
+          <div class="approvals-view__people-title">
+            {{ isTownReview ? '村合作社提交名单' : '本村人员列表' }}
+            <span v-if="currentRow?.village">
+              ({{ currentRow.town }}{{ currentRow.village }} · 批次 {{ currentRow.batchNo }})
+            </span>
+            <em>共 {{ detailPeople.length }} 人</em>
+            <em v-if="detailStatusFilter">,当前 {{ filteredDetailPeople.length }} 人</em>
+            <em v-if="isTownReview && pendingQualifyCount > 0" class="approvals-view__people-warn">
+              待核验 {{ pendingQualifyCount }} 人
+            </em>
+          </div>
+          <el-select
+            v-model="detailStatusFilter"
+            clearable
+            filterable
+            placeholder="人员状态"
+            size="small"
+            class="approvals-view__status-filter"
+            @change="resetDetailPeoplePage"
+          >
+            <el-option
+              v-for="s in detailStatusOptions"
+              :key="s"
+              :label="s"
+              :value="s"
+            />
+          </el-select>
         </div>
         <el-table
           v-loading="peopleLoading"
@@ -219,6 +258,11 @@
           description="该村本批次暂无人员数据"
           :image-size="64"
         />
+        <el-empty
+          v-else-if="!peopleLoading && detailPeople.length && !filteredDetailPeople.length"
+          description="当前筛选条件下无人员"
+          :image-size="64"
+        />
       </div>
 
       <template v-if="currentRow?.status === '待审批'" #footer>
@@ -303,7 +347,14 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
-import { decideApproval, fetchApprovals, fetchPeople, reviewPersonQualify } from '@/api/biz'
+import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import {
+  decideApproval,
+  fetchApprovals,
+  fetchBatchMemberPeople,
+  reviewPersonQualify,
+} from '@/api/biz'
+import { parseApprovalId } from '@/api/sjnmtybt/mappers'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
@@ -335,11 +386,29 @@ const rejectVisible = ref(false)
 const rejectOpinion = ref('')
 const rejectTarget = ref<Approval | null>(null)
 const rejectBatchMode = ref(false)
+const batchDrawerVisible = ref(false)
+const batchDrawerId = ref('')
+
+function batchIdOfApproval(row: Approval): string {
+  const parsed = parseApprovalId(String(row.id || ''))
+  if (parsed?.kind === 'batch') return parsed.bizId
+  return ''
+}
+
+function openBatchFlow(row: Approval) {
+  const id = batchIdOfApproval(row)
+  if (!id) {
+    ElMessage.info('该审批单无关联批次流程')
+    return
+  }
+  batchDrawerId.value = id
+  batchDrawerVisible.value = true
+}
 
 const qualifyVisible = ref(false)
 const qualifyTarget = ref<Person | null>(null)
 const qualifyType = ref<'有土地退养' | '无土地有资格'>('有土地退养')
-const reviewingId = ref<number | null>(null)
+const reviewingId = ref<number | string | null>(null)
 
 const filteredList = computed(() => {
   if (!isAdmin.value) return list.value
@@ -356,13 +425,25 @@ function resetFilter() {
   resetTownVillage()
   resetPage()
 }
+const detailStatusFilter = ref('')
+const detailStatusOptions = computed(() => {
+  const set = new Set<string>()
+  for (const p of detailPeople.value) {
+    if (p.status) set.add(p.status)
+  }
+  return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'))
+})
+const filteredDetailPeople = computed(() => {
+  if (!detailStatusFilter.value) return detailPeople.value
+  return detailPeople.value.filter((p) => p.status === detailStatusFilter.value)
+})
 const {
   page: detailPeoplePage,
   pageSize: detailPeoplePageSize,
   total: detailPeopleTotal,
   pagedList: pagedDetailPeople,
   resetPage: resetDetailPeoplePage,
-} = useClientPagination(detailPeople)
+} = useClientPagination(filteredDetailPeople)
 
 /** 按角色限定审批节点 */
 const approvalNode = computed(() => {
@@ -459,17 +540,25 @@ async function confirmQualify() {
   }
 }
 
-/** 打开详情并加载该村本批次人员 */
+/** 打开详情:按批次发放明细加载申报名单(不是全村人员库) */
 async function openDetail(row: Approval) {
   currentRow.value = row
   detailVisible.value = true
   detailPeople.value = []
+  detailStatusFilter.value = ''
   peopleLoading.value = true
   try {
-    detailPeople.value = await fetchPeople({
+    const batchId = row.batchId || batchIdOfApproval(row)
+    if (!batchId) {
+      detailPeople.value = []
+      return
+    }
+    detailPeople.value = await fetchBatchMemberPeople(batchId, {
       batchNo: row.batchNo,
       town: row.town,
       village: row.village,
+      // 已提交镇审:以当时发放明细为准,不再做默认剔除
+      pruneDefaults: false,
     })
     resetDetailPeoplePage()
   } finally {
@@ -480,6 +569,7 @@ async function openDetail(row: Approval) {
 function onDetailClosed() {
   currentRow.value = null
   detailPeople.value = []
+  detailStatusFilter.value = ''
 }
 
 async function doDecide(row: Approval, pass: boolean, opinion?: string) {
@@ -649,8 +739,17 @@ watch(
 .approvals-view__people {
   margin-top: 16px;
 
-  &-title {
+  &-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
     margin-bottom: 8px;
+  }
+
+  &-title {
+    flex: 1;
+    min-width: 0;
     font-size: 13px;
     font-weight: 600;
     color: @text-primary;
@@ -674,6 +773,11 @@ watch(
   }
 }
 
+.approvals-view__status-filter {
+  width: 140px;
+  flex-shrink: 0;
+}
+
 .approvals-view__reject-tip,
 .approvals-view__qualify-tip {
   margin: 0 0 12px;

+ 304 - 29
src/views/base/BaseInfoView.vue

@@ -6,8 +6,6 @@
         <div class="block-title base-info-view__title">人员新增录入</div>
         <div class="base-info-view__toolbar">
           <el-button type="primary" size="small" @click="openPersonDialog">单人信息录入</el-button>
-          <el-button size="small" @click="onMockImport">模拟 Excel 批量导入</el-button>
-          <el-button size="small" @click="onDownloadStats">下载发放统计表</el-button>
         </div>
       </div>
       <p class="base-info-view__desc">
@@ -18,7 +16,7 @@
           系统管理员可按镇+村查看与操作全区数据;请先选择街镇与村居后再录入或提交。
         </template>
         <template v-else>
-          仅可查看与操作本村批次及人员;录入后提交镇经发中心初审,退回修改后可再次提交。
+          仅可查看与操作本村批次及人员;未提交初审前可「选人」调整名单(暂停/停止/死亡丧葬默认不纳入),确认后提交镇经发中心初审,退回修改后可再次提交。
         </template>
       </p>
       <el-form v-if="isAdmin" :inline="true" size="small" class="base-info-view__admin-filter">
@@ -75,20 +73,54 @@
                 <StatusTag :status="row.stage" />
               </template>
             </el-table-column>
-            <el-table-column prop="submittedAt" label="提交时间" min-width="140" />
-            <el-table-column label="操作" width="120" align="center" fixed="right">
+            <el-table-column label="提交时间" min-width="160">
               <template #default="{ row }">
+                {{ (row as VillageBatch).submittedAt || '—' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="320" align="center" fixed="right">
+              <template #default="{ row }">
+                <el-button
+                  link
+                  type="primary"
+                  size="small"
+                  @click="openBatchDrawer(row as VillageBatch, 'detail')"
+                >
+                  批次详情
+                </el-button>
+                <el-button
+                  link
+                  type="primary"
+                  size="small"
+                  @click="openBatchDrawer(row as VillageBatch, 'flow')"
+                >
+                  流程记录
+                </el-button>
+                <el-button
+                  v-if="canEditMembers(row as VillageBatch)"
+                  link
+                  type="warning"
+                  size="small"
+                  @click="openMemberDialog(row as VillageBatch)"
+                >
+                  选人
+                </el-button>
                 <el-button
                   v-if="canSubmit(row as VillageBatch)"
                   link
-                  :type="row.stage === '退回修改' ? 'danger' : 'primary'"
+                  :type="row.stage === '退回修改' ? 'danger' : 'success'"
                   size="small"
+                  :loading="submittingId === (row as VillageBatch).id"
                   @click="onSubmitBatch(row as VillageBatch)"
                 >
-                  {{ row.stage === '退回修改' ? '修改后重新提交' : '提交初审' }}
+                  {{ row.stage === '退回修改' ? '重新提交' : '提交初审' }}
                 </el-button>
-                <span v-else-if="!isLatestBatch(row as VillageBatch)" class="muted">历史批次</span>
-                <span v-else class="muted">—</span>
+                <span
+                  v-else-if="!canEditMembers(row as VillageBatch) && !isLatestBatch(row as VillageBatch)"
+                  class="muted"
+                >
+                  历史
+                </span>
               </template>
             </el-table-column>
           </el-table>
@@ -101,6 +133,68 @@
         />
       </PagePanel>
 
+      <BatchDetailDrawer
+        v-model="batchDrawerVisible"
+        :batch-id="batchDrawerId"
+        :initial-tab="batchDrawerTab"
+      />
+
+      <!-- 未提交初审:选择纳入本批次人员 -->
+      <el-dialog
+        v-model="memberDialogVisible"
+        :title="`选择纳入人员 · ${memberBatch?.batchNo || ''}`"
+        width="860px"
+        destroy-on-close
+        @closed="onMemberDialogClosed"
+      >
+        <p class="base-info-view__dialog-tip">
+          未提交初审前可调整名单。暂停、停止、死亡丧葬人员默认不纳入,一般勿勾选;保存后写入本批次发放明细。
+        </p>
+        <el-table
+          ref="memberTableRef"
+          v-loading="memberLoading"
+          :data="memberCandidates"
+          border
+          stripe
+          size="small"
+          max-height="420"
+          row-key="id"
+          @selection-change="onMemberSelectionChange"
+        >
+          <el-table-column type="selection" width="48" :selectable="isMemberSelectable" />
+          <el-table-column prop="name" label="姓名" width="80" />
+          <el-table-column label="身份证" min-width="160">
+            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          </el-table-column>
+          <el-table-column label="状态" width="110" align="center">
+            <template #default="{ row }">
+              <StatusTag :status="row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column label="标准(元/月)" width="100" align="right">
+            <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+          </el-table-column>
+          <el-table-column label="银行卡" min-width="160">
+            <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
+          </el-table-column>
+        </el-table>
+        <p class="base-info-view__member-count">已选 {{ memberSelectedIds.length }} 人</p>
+        <template #footer>
+          <el-button @click="memberDialogVisible = false">取消</el-button>
+          <el-button type="primary" :loading="memberSaving" @click="saveMembers">
+            保存人选
+          </el-button>
+          <el-button
+            type="warning"
+            :loading="memberSaving"
+            :disabled="!memberSelectedIds.length"
+            @click="saveMembersAndSubmit"
+          >
+            保存并提交初审
+          </el-button>
+        </template>
+      </el-dialog>
+
       <!-- 本村发放人历史记录 -->
       <PagePanel :title="isAdmin ? '发放人历史记录' : '本村发放人历史记录'" class="base-info-view__panel">
         <div class="base-info-view__table-wrap">
@@ -223,12 +317,21 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref, watch } from 'vue'
-import { ElMessage } from 'element-plus'
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import type { TableInstance } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
-import { fetchPeople, fetchVillageBatches, savePerson, submitVillageBatch } from '@/api/biz'
+import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import {
+  fetchBatchMemberIds,
+  fetchPeople,
+  fetchVillageBatches,
+  savePerson,
+  submitVillageBatch,
+  updateBatchMembers,
+} from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
@@ -261,7 +364,35 @@ const batchLoading = ref(false)
 const peopleLoading = ref(false)
 const personDialogVisible = ref(false)
 const saving = ref(false)
-const submitting = ref(false)
+const submittingId = ref('')
+const batchDrawerVisible = ref(false)
+const batchDrawerId = ref('')
+const batchDrawerTab = ref('detail')
+
+const memberDialogVisible = ref(false)
+const memberLoading = ref(false)
+const memberSaving = ref(false)
+const memberBatch = ref<VillageBatch | null>(null)
+const memberCandidates = ref<Person[]>([])
+const memberSelectedIds = ref<string[]>([])
+const memberTableRef = ref<TableInstance>()
+
+/** 默认不纳入(也不建议勾选)的人员状态 */
+const EXCLUDED_MEMBER_STATUSES = ['暂停', '停止', '死亡丧葬', '已归档', '已停止发放']
+
+function isExcludedMemberStatus(status?: string) {
+  return EXCLUDED_MEMBER_STATUSES.includes(String(status || ''))
+}
+
+function canEditMembers(row: VillageBatch) {
+  if (isAdjustBatch(row)) return false
+  return row.stage === '人员新增录入' || row.stage === '退回修改'
+}
+
+function isMemberSelectable(row: Person) {
+  // 允许勾选以便特殊场景,但默认不会预选;归档不可选
+  return row.status !== '已归档'
+}
 
 const {
   page: batchPage,
@@ -309,21 +440,40 @@ const personForm = reactive({
   remark: '',
 })
 
-/** 本村最新批次 id(按月份/批次号最大) */
+/** 当前发放周期 yyyyMM */
+const currentPayMonth = computed(() =>
+  String(userStore.currentMonth || '').replace(/-/g, ''),
+)
+
+/** 是否调标补发批(YYYY99,不占日常「最新批次」) */
+function isAdjustBatch(b: VillageBatch) {
+  const no = String(b.batchNo || '').replace(/-/g, '')
+  return no.length >= 6 && no.endsWith('99')
+}
+
+/** 本村最新日常批次 id(排除调标 YYYY99) */
 const latestBatchId = computed(() => {
-  if (!batches.value.length) return ''
-  let latest = batches.value[0]
-  for (const b of batches.value) {
+  const daily = batches.value.filter((b) => !isAdjustBatch(b))
+  if (!daily.length) return ''
+  let latest = daily[0]
+  for (const b of daily) {
     if (compareBatchRecency(b, latest) > 0) latest = b
   }
   return latest.id
 })
 
+/** 排序键:合法发放月(20xxxx)优先;历史脏号(如 214808)视为更旧 */
+function batchRecencyKey(b: VillageBatch) {
+  const raw = String(b.month || b.batchNo || '').replace(/-/g, '')
+  if (/^20\d{4}$/.test(raw)) return raw
+  return `0${raw}`
+}
+
 /** 比较批次新旧:>0 表示 a 比 b 更新 */
 function compareBatchRecency(a: VillageBatch, b: VillageBatch) {
-  const am = a.month || a.batchNo
-  const bm = b.month || b.batchNo
-  if (am !== bm) return am > bm ? 1 : -1
+  const ak = batchRecencyKey(a)
+  const bk = batchRecencyKey(b)
+  if (ak !== bk) return ak > bk ? 1 : -1
   return a.id > b.id ? 1 : a.id < b.id ? -1 : 0
 }
 
@@ -331,10 +481,13 @@ function isLatestBatch(row: VillageBatch) {
   return row.id === latestBatchId.value
 }
 
-/** 仅最新批次且处于录入中/退回修改时可提交初审 */
+/** 本发放周期草稿/退回批,或最新日常批处于录入中时可提交初审 */
 function canSubmit(row: VillageBatch) {
-  if (!isLatestBatch(row)) return false
-  return row.stage === '人员新增录入' || row.stage === '退回修改'
+  if (row.stage !== '人员新增录入' && row.stage !== '退回修改') return false
+  if (isAdjustBatch(row)) return false
+  const no = String(row.batchNo || '').replace(/-/g, '')
+  if (currentPayMonth.value && no === currentPayMonth.value) return true
+  return isLatestBatch(row)
 }
 
 function onAdminTownChange() {
@@ -365,12 +518,127 @@ function openPersonDialog() {
   personDialogVisible.value = true
 }
 
-function onMockImport() {
-  ElMessage.info('演示:Excel 批量导入待对接接口')
+function openBatchDrawer(row: VillageBatch, tab: 'detail' | 'flow' = 'detail') {
+  if (!row.id) {
+    ElMessage.warning('批次ID缺失,无法查看')
+    return
+  }
+  batchDrawerId.value = row.id
+  batchDrawerTab.value = tab
+  batchDrawerVisible.value = true
 }
 
-function onDownloadStats() {
-  ElMessage.info('演示:统计表下载待对接接口')
+async function openMemberDialog(row: VillageBatch) {
+  if (!canEditMembers(row)) {
+    ElMessage.warning('该批次已进入审批/发放,不能再改人选')
+    return
+  }
+  memberBatch.value = row
+  memberDialogVisible.value = true
+  memberLoading.value = true
+  memberSelectedIds.value = []
+  try {
+    const [allPeople, pickedIds] = await Promise.all([
+      fetchPeople({ town: row.town, village: row.village }),
+      fetchBatchMemberIds(row.id),
+    ])
+    // 候选:本村人员(归档除外);暂停/停止/死亡仍列出但默认不勾选
+    memberCandidates.value = allPeople.filter((p) => p.status !== '已归档')
+    const picked = new Set(pickedIds.map(String))
+    await nextTick()
+    const table = memberTableRef.value
+    if (!table) return
+    table.clearSelection()
+    if (picked.size) {
+      memberCandidates.value.forEach((p) => {
+        // 已在册但属排除状态:不预勾选(保存时相当于剔除)
+        if (picked.has(String(p.id)) && !isExcludedMemberStatus(p.status)) {
+          table.toggleRowSelection(p, true)
+        }
+      })
+    } else {
+      memberCandidates.value.forEach((p) => {
+        if (!isExcludedMemberStatus(p.status) && (p.status === '正常在库' || p.status === '草稿')) {
+          table.toggleRowSelection(p, true)
+        }
+      })
+    }
+  } finally {
+    memberLoading.value = false
+  }
+}
+
+function onMemberSelectionChange(rows: Person[]) {
+  memberSelectedIds.value = (rows || []).map((r) => String(r.id))
+}
+
+function onMemberDialogClosed() {
+  memberBatch.value = null
+  memberCandidates.value = []
+  memberSelectedIds.value = []
+}
+
+async function persistMembers() {
+  if (!memberBatch.value?.id) return
+  await updateBatchMembers(memberBatch.value.id, memberSelectedIds.value)
+}
+
+async function saveMembers() {
+  if (!memberBatch.value?.id) return
+  if (!memberSelectedIds.value.length) {
+    ElMessage.warning('请至少选择一名人员')
+    return
+  }
+  const excludedPicked = memberCandidates.value.filter(
+    (p) => memberSelectedIds.value.includes(String(p.id)) && isExcludedMemberStatus(p.status),
+  )
+  if (excludedPicked.length) {
+    try {
+      await ElMessageBox.confirm(
+        `已勾选 ${excludedPicked.length} 名暂停/停止/死亡等人员,确认仍纳入本批次?`,
+        '确认人选',
+        { type: 'warning' },
+      )
+    } catch {
+      return
+    }
+  }
+  memberSaving.value = true
+  try {
+    await persistMembers()
+    ElMessage.success('人选已保存')
+    memberDialogVisible.value = false
+    await loadBatches()
+  } finally {
+    memberSaving.value = false
+  }
+}
+
+async function saveMembersAndSubmit() {
+  if (!memberBatch.value?.id) return
+  if (!memberSelectedIds.value.length) {
+    ElMessage.warning('请至少选择一名人员')
+    return
+  }
+  try {
+    await ElMessageBox.confirm(
+      `确认将 ${memberSelectedIds.value.length} 人保存并提交镇经发初审?`,
+      '提交初审',
+      { type: 'warning' },
+    )
+  } catch {
+    return
+  }
+  memberSaving.value = true
+  try {
+    await persistMembers()
+    await submitVillageBatch(memberBatch.value.id)
+    ElMessage.success('已提交镇经发中心初审')
+    memberDialogVisible.value = false
+    await loadBatches()
+  } finally {
+    memberSaving.value = false
+  }
 }
 
 /** 提交至镇经发中心初审(仅允许最新批次) */
@@ -379,7 +647,7 @@ async function onSubmitBatch(row: VillageBatch) {
     ElMessage.warning('仅最新批次可提交初审,历史批次不可操作')
     return
   }
-  submitting.value = true
+  submittingId.value = row.id
   try {
     await submitVillageBatch(row.id)
     ElMessage.success(
@@ -387,7 +655,7 @@ async function onSubmitBatch(row: VillageBatch) {
     )
     await loadBatches()
   } finally {
-    submitting.value = false
+    submittingId.value = ''
   }
 }
 
@@ -580,6 +848,13 @@ watch(
     line-height: 1.6;
   }
 
+  &__member-count {
+    margin: 10px 0 0;
+    font-size: 12px;
+    color: @primary-dark;
+    font-weight: 600;
+  }
+
   .muted {
     color: @text-secondary;
     font-size: 12px;

+ 120 - 11
src/views/dashboard/DashboardView.vue

@@ -25,7 +25,20 @@
             </p>
           </div>
         </div>
-        <div class="batch-overview__period">发放周期 {{ userStore.currentMonth }}</div>
+        <div class="batch-overview__period">
+          <span class="batch-overview__period-label">发放周期</span>
+          <el-date-picker
+            :model-value="userStore.currentMonth"
+            type="month"
+            value-format="YYYY-MM"
+            format="YYYY-MM"
+            size="small"
+            placeholder="选择月份"
+            :clearable="false"
+            class="batch-overview__period-picker"
+            @update:model-value="onMonthChange"
+          />
+        </div>
       </div>
 
       <div class="batch-overview__metrics">
@@ -252,33 +265,67 @@
         </PagePanel>
       </div>
 
-      <PagePanel title="全区各街镇土地退养概况">
-        <el-table :data="data?.districtOverview?.townSummary ?? []" border stripe size="small">
-          <el-table-column prop="town" label="街镇" width="110" />
-          <el-table-column prop="villageCount" label="涉及村数" width="90" align="center" />
-          <el-table-column prop="peopleCount" label="人数" width="88" align="center" />
+      <PagePanel title="全区各街镇土地退养概况" class="district-overview-panel">
+        <el-table
+          class="district-overview-table"
+          :data="data?.districtOverview?.townSummary ?? []"
+          border
+          stripe
+          size="small"
+          table-layout="fixed"
+        >
+          <el-table-column prop="town" label="街镇" min-width="100" align="center" />
+          <el-table-column
+            prop="villageCount"
+            label="涉及村数"
+            min-width="88"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            prop="peopleCount"
+            label="人数"
+            min-width="80"
+            align="center"
+            header-align="center"
+          />
           <el-table-column
             v-if="isHr"
             label="预计金额(元)"
-            width="130"
+            min-width="120"
             align="right"
+            header-align="center"
           >
             <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
           </el-table-column>
-          <el-table-column prop="withLandCount" label="有土地退养" width="110" align="center" />
-          <el-table-column prop="withoutLandCount" label="无土地有资格" width="120" align="center" />
+          <el-table-column
+            prop="withLandCount"
+            label="有土地退养"
+            min-width="100"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            prop="withoutLandCount"
+            label="无土地有资格"
+            min-width="110"
+            align="center"
+            header-align="center"
+          />
           <el-table-column
             v-if="isAgri"
             prop="qualifiedCount"
             label="资格已核验/标注"
-            width="130"
+            min-width="120"
             align="center"
+            header-align="center"
           />
           <el-table-column
             v-if="isAgri"
             label="预计金额(元)"
             min-width="120"
             align="right"
+            header-align="center"
           >
             <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
           </el-table-column>
@@ -927,6 +974,12 @@ const districtHalfYearAmountOption = computed(() =>
   ),
 )
 
+/** 切换发放周期(写入 store,各角色工作台/业务页共用) */
+function onMonthChange(month: string | null) {
+  if (!month) return
+  userStore.setMonth(month)
+}
+
 /** 按当前角色与发放周期加载工作台 */
 async function loadData() {
   loading.value = true
@@ -1001,6 +1054,38 @@ watch(
   }
 }
 
+/** 区级概况表:列宽均分,避免前几列挤左侧、金额飘到最右 */
+.district-overview-panel {
+  :deep(.page-panel__body),
+  :deep(.content-panel__body) {
+    padding-top: 4px;
+  }
+}
+
+.district-overview-table {
+  width: 100%;
+
+  :deep(.el-table__header th),
+  :deep(.el-table__body td) {
+    padding: 8px 6px;
+  }
+
+  :deep(.el-table__header th.el-table__cell) {
+    background: #f5f2eb;
+    color: @text-regular;
+    font-weight: 600;
+  }
+
+  :deep(.el-table__body .cell) {
+    line-height: 1.4;
+    white-space: nowrap;
+  }
+
+  :deep(.el-table__row:hover > td.el-table__cell) {
+    background: #faf6ee !important;
+  }
+}
+
 .village-workspace {
   flex: 1;
   min-height: 520px;
@@ -1209,7 +1294,10 @@ watch(
 
   &__period {
     flex-shrink: 0;
-    padding: 4px 10px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 2px 6px 2px 10px;
     font-size: 12px;
     color: @primary-dark;
     background: rgba(201, 166, 110, 0.12);
@@ -1217,6 +1305,27 @@ watch(
     border-radius: @radius-sm;
   }
 
+  &__period-label {
+    flex-shrink: 0;
+    white-space: nowrap;
+  }
+
+  &__period-picker {
+    width: 120px;
+
+    :deep(.el-input__wrapper) {
+      background: #fff;
+      box-shadow: none;
+      border: 1px solid rgba(201, 166, 110, 0.45);
+    }
+
+    :deep(.el-input__inner) {
+      font-size: 12px;
+      color: @primary-dark;
+      font-weight: 600;
+    }
+  }
+
   &__metrics {
     display: grid;
     grid-template-columns: repeat(4, 1fr);

+ 55 - 9
src/views/disk/DiskReturnView.vue

@@ -5,8 +5,13 @@
         <el-button type="primary" size="small" @click="openUploadDialog">回盘文件上传</el-button>
       </template>
 
-      <p v-if="isAdmin" class="disk-return-view__tip">
-        系统管理员可查看全区各镇回盘记录,请按街镇筛选后操作。
+      <p class="disk-return-view__tip">
+        <template v-if="isAdmin">
+          系统管理员可查看全区各镇回盘记录,请按街镇筛选后操作。
+        </template>
+        <template v-else>
+          出盘后在此上传回盘文件;全部成功或部分失败处理完后点「归档」完成本月闭环。失败人员可查看原因并处理告警。
+        </template>
       </p>
       <div v-if="isAdmin" class="query-panel disk-return-view__filter">
         <el-form :inline="true" size="small">
@@ -48,7 +53,7 @@
         <el-table-column label="失败人数" width="88" align="center">
           <template #default="{ row }">{{ row.failCount ?? 0 }}</template>
         </el-table-column>
-        <el-table-column label="操作" width="100" align="center" fixed="right">
+        <el-table-column label="操作" width="180" align="center" fixed="right">
           <template #default="{ row }">
             <el-button
               v-if="row.returnStatus === '部分失败'"
@@ -57,9 +62,24 @@
               size="small"
               @click="openFailDialog(row as DiskRecord)"
             >
-              查看
+              失败人员
             </el-button>
-            <span v-else class="muted">—</span>
+            <el-button
+              v-if="canArchive(row as DiskRecord)"
+              link
+              type="warning"
+              size="small"
+              :loading="archivingId === row.id"
+              @click="onArchive(row as DiskRecord)"
+            >
+              归档
+            </el-button>
+            <span
+              v-if="row.returnStatus !== '部分失败' && !canArchive(row as DiskRecord)"
+              class="muted"
+            >
+              —
+            </span>
           </template>
         </el-table-column>
       </el-table>
@@ -74,9 +94,9 @@
       destroy-on-close
       @closed="onUploadDialogClosed"
     >
-      <!-- <p class="disk-return-view__dialog-tip">
-        请选择本镇待回盘批次并上传回盘文件。后台将匹配文件中发放失败人员信息,并对相关人员生成监控告警。常见失败原因:001社会保险登记码不存在、002清户、003冻结、004用户撤销、005保险号不存在、账号不一致、金额不足。
-      </p> -->
+      <p class="disk-return-view__dialog-tip">
+        请选择本镇已出盘批次并上传回盘文件。系统将解析失败人员并生成告警。常见原因:社会保险登记码不存在、清户、冻结、用户撤销、保险号不存在、账号不一致、金额不足。
+      </p>
       <el-form label-width="96px" size="small">
         <el-form-item label="批次" required>
           <el-select
@@ -182,11 +202,12 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
 import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import {
+  archiveToNextMonth,
   confirmDiskFailPaid,
   fetchDiskFailPeople,
   fetchDisks,
@@ -204,6 +225,7 @@ const { filterTown, townOptions, resetTownVillage, matchTownVillage } = useTownV
 
 const list = ref<DiskRecord[]>([])
 const loading = ref(false)
+const archivingId = ref('')
 
 const uploadDialogVisible = ref(false)
 const uploading = ref(false)
@@ -299,6 +321,30 @@ async function submitUpload() {
   }
 }
 
+function canArchive(row: DiskRecord) {
+  return row.status === '已回盘' || row.returnStatus === '成功' || row.returnStatus === '部分失败'
+}
+
+async function onArchive(row: DiskRecord) {
+  try {
+    await ElMessageBox.confirm(
+      `确认将批次 ${row.batchNo}(${row.town})归档?归档后本月业务闭环,不再出现在待办列表。`,
+      '归档确认',
+      { type: 'warning', confirmButtonText: '确认归档', cancelButtonText: '取消' },
+    )
+  } catch {
+    return
+  }
+  archivingId.value = row.id
+  try {
+    await archiveToNextMonth(row.id)
+    ElMessage.success('批次已归档')
+    await loadData()
+  } finally {
+    archivingId.value = ''
+  }
+}
+
 async function openFailDialog(row: DiskRecord) {
   currentDisk.value = row
   failDialogVisible.value = true

+ 41 - 7
src/views/insurance/InsuranceMatchView.vue

@@ -12,7 +12,7 @@
         </el-button>
       </template>
       <p class="insurance-match-view__tip">
-        与人社/居保数据交叉核验。可单条匹配,也可勾选待匹配村居进行批量匹配;已匹配完成的村居不会被「全选」勾中。点击已匹配村居可查看该村本批次人员居保匹配情况。
+        与人社/居保交叉核验。匹配后:全部通过可直接到「月补贴清单」提交领导;有 FAIL 须在此「人工确认」处理完,再到月补贴页提交分管领导。已匹配完成的村居不会被「全选」勾中。
         <template v-if="isAdmin">系统管理员可按街镇+村居筛选全区批次。</template>
       </p>
       <div v-if="isAdmin" class="query-panel insurance-match-view__filter">
@@ -81,13 +81,29 @@
         </el-table-column>
         <el-table-column prop="submitter" label="提交人" width="110" />
         <el-table-column prop="submittedAt" label="提交时间" min-width="140" />
-        <el-table-column label="操作" width="120" align="center" fixed="right">
+        <el-table-column label="操作" width="280" align="center" fixed="right">
           <template #default="{ row }">
             <el-button
-              v-if="row.stage === '居保状态匹配'"
               link
               type="primary"
               size="small"
+              @click="openBatchDrawer(row as VillageBatch, 'detail')"
+            >
+              批次详情
+            </el-button>
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="openBatchDrawer(row as VillageBatch, 'flow')"
+            >
+              流程记录
+            </el-button>
+            <el-button
+              v-if="row.stage === '居保状态匹配'"
+              link
+              type="success"
+              size="small"
               @click="onMatchOne(row as VillageBatch)"
             >
               匹配
@@ -95,11 +111,11 @@
             <el-button
               v-else-if="row.stage === '比对结果已生成'"
               link
-              type="primary"
+              type="success"
               size="small"
               @click="openVillageResult(row as VillageBatch)"
             >
-              查看匹配结果
+              匹配结果
             </el-button>
           </template>
         </el-table-column>
@@ -111,6 +127,12 @@
       />
     </PagePanel>
 
+    <BatchDetailDrawer
+      v-model="batchDrawerVisible"
+      :batch-id="batchDrawerId"
+      :initial-tab="batchDrawerTab"
+    />
+
     <!-- 各村人员居保匹配结果弹窗 -->
     <el-dialog
       v-model="resultDialogVisible"
@@ -177,6 +199,7 @@ import {
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
+import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import { maskIdCard, formatAmount } from '@/utils/mask'
 import type { Person, VillageBatch } from '@/types/biz'
@@ -203,6 +226,16 @@ const tableRef = ref<TableInstance>()
 
 const resultDialogVisible = ref(false)
 const currentBatch = ref<VillageBatch | null>(null)
+const batchDrawerVisible = ref(false)
+const batchDrawerId = ref('')
+const batchDrawerTab = ref('detail')
+
+function openBatchDrawer(row: VillageBatch, tab: 'detail' | 'flow' = 'detail') {
+  if (!row.id) return
+  batchDrawerId.value = row.id
+  batchDrawerTab.value = tab
+  batchDrawerVisible.value = true
+}
 
 const resultDialogTitle = computed(() => {
   const b = currentBatch.value
@@ -292,6 +325,7 @@ async function loadVillageResults(row: VillageBatch) {
   resultLoading.value = true
   try {
     results.value = await fetchInsuranceResults({
+      batchId: row.id,
       batchNo: row.batchNo,
       town: row.town,
       village: row.village,
@@ -308,8 +342,8 @@ function onResultDialogClosed() {
 }
 
 async function onManualConfirm(row: Person) {
-  await confirmInsuranceManual(row.id)
-  ElMessage.success('人工确认通过')
+  await confirmInsuranceManual(row.id, currentBatch.value?.id)
+  ElMessage.success('人工确认通过。全部处理完后请到「月补贴清单」提交分管领导审核')
   if (currentBatch.value) {
     await loadVillageResults(currentBatch.value)
   }

+ 61 - 14
src/views/monthly/MonthlyPaymentView.vue

@@ -6,7 +6,7 @@
           系统管理员可查看全区各镇各村月补贴批次,请按街镇+村居筛选后操作。
         </template>
         <template v-else>
-          展示本镇各村月补贴批次。已审核可出盘,出盘后(已出盘)方可导出清单;本批次另可提交分管领导审核。历史批次不可提交审核。回盘请在「回盘信息确认」办理。
+          流程:居保处理完 →「待提交」点提交分管领导 →「待审核」领导通过后变「已审核」→ 出盘 → 回盘页上传回盘。历史批次不可再提交审核。
         </template>
       </p>
       <div v-if="isAdmin" class="query-panel monthly-payment-view__filter">
@@ -57,10 +57,26 @@
           </template>
         </el-table-column>
         <el-table-column prop="submittedAt" label="提交时间" min-width="140" />
-        <el-table-column label="操作" width="220" align="center" fixed="right">
+        <el-table-column label="操作" width="300" align="center" fixed="right">
           <template #default="{ row }">
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="openBatchDrawer(row as VillageMonthlyBatch, 'detail')"
+            >
+              批次详情
+            </el-button>
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="openBatchDrawer(row as VillageMonthlyBatch, 'flow')"
+            >
+              流程记录
+            </el-button>
             <el-button link type="primary" size="small" @click="openDetail(row as VillageMonthlyBatch)">
-              查看
+              人员明细
             </el-button>
             <!-- 本批次待提交:提交分管领导审核 -->
             <el-button
@@ -71,7 +87,7 @@
               :loading="actingId === row.id"
               @click="onSubmitLeader(row as VillageMonthlyBatch)"
             >
-              提交分管领导审核
+              提交领导
             </el-button>
             <!-- 本批次待审核 -->
             <span
@@ -109,7 +125,13 @@
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
-    <!-- 批次详情弹窗 -->
+    <BatchDetailDrawer
+      v-model="batchDrawerVisible"
+      :batch-id="batchDrawerId"
+      :initial-tab="batchDrawerTab"
+    />
+
+    <!-- 人员明细弹窗(快速查看) -->
     <el-dialog
       v-model="detailVisible"
       :title="detailTitle"
@@ -177,9 +199,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
+import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import {
   exportMonthlyFile,
-  fetchPeople,
+  fetchBatchMemberPeople,
   fetchVillageMonthlyBatches,
   issueVillageMonthlyDisk,
   submitToLeader,
@@ -209,6 +232,16 @@ const detailVisible = ref(false)
 const detailLoading = ref(false)
 const currentRow = ref<VillageMonthlyBatch | null>(null)
 const detailPeople = ref<Person[]>([])
+const batchDrawerVisible = ref(false)
+const batchDrawerId = ref('')
+const batchDrawerTab = ref('detail')
+
+function openBatchDrawer(row: VillageMonthlyBatch, tab: 'detail' | 'flow' = 'detail') {
+  if (!row.id) return
+  batchDrawerId.value = row.id
+  batchDrawerTab.value = tab
+  batchDrawerVisible.value = true
+}
 
 const filteredList = computed(() => {
   if (!isAdmin.value) return list.value
@@ -253,12 +286,14 @@ async function openDetail(row: VillageMonthlyBatch) {
   detailVisible.value = true
   detailLoading.value = true
   try {
-    const people = await fetchPeople({
-      batchNo: row.batchNo,
-      town: row.town,
-      village: row.village,
-    })
-    detailPeople.value = people
+    detailPeople.value = row.id
+      ? await fetchBatchMemberPeople(row.id, {
+          batchNo: row.batchNo,
+          town: row.town,
+          village: row.village,
+          pruneDefaults: false,
+        })
+      : []
     resetDetailPage()
   } finally {
     detailLoading.value = false
@@ -323,8 +358,20 @@ async function onIssueDisk(row: VillageMonthlyBatch) {
 }
 
 async function onExportFile(row: VillageMonthlyBatch) {
-  const res = await exportMonthlyFile(row.id)
-  ElMessage.success(`清单已导出(${(res as { fileName?: string }).fileName || '演示'})`)
+  if (row.status === '已审核' || row.status === '待审核' || row.status === '待提交') {
+    ElMessage.info('请先完成出盘后再导出对照文件;出盘成功后文件名会显示在批次信息中')
+    return
+  }
+  const res = (await exportMonthlyFile(row.id)) as { ok?: boolean; fileName?: string; message?: string }
+  if (res?.ok === false) {
+    ElMessage.warning(res.message || '导出失败')
+    return
+  }
+  ElMessage.success(
+    res?.fileName
+      ? `出盘文件:${res.fileName}(线下签字发放,系统不提供资金支付)`
+      : '已生成对照文件,请按线下流程办理',
+  )
 }
 
 async function loadData() {

+ 86 - 102
src/views/reports/ReportsView.vue

@@ -7,37 +7,37 @@
 
     <!-- 区级:按街镇汇总对比 -->
     <PagePanel v-if="isDistrict" :title="isAgri ? '全区各街镇资格汇总' : isHr ? '全区各街镇发放汇总' : '全区各街镇汇总'">
-      <el-table :data="pagedTownSummary" border stripe size="small">
-        <el-table-column prop="town" label="街镇" width="110" />
-        <el-table-column prop="batchCount" label="批次数" width="88" align="center" />
-        <el-table-column prop="peopleCount" label="人数" width="88" align="center" />
+      <el-table :data="pagedTownSummary" border stripe size="small" style="width: 100%">
+        <el-table-column prop="town" label="街镇" min-width="120" />
+        <el-table-column prop="batchCount" label="批次数" min-width="100" align="center" />
+        <el-table-column prop="peopleCount" label="人数" min-width="100" align="center" />
         <!-- 人社优先展示金额;农委优先展示资格后再带金额 -->
         <el-table-column
           v-if="isHr || isAdminDistrict"
           label="预计金额(元)"
-          width="140"
+          min-width="140"
           align="right"
         >
           <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
         </el-table-column>
-        <el-table-column prop="withLandCount" label="有土地退养" width="110" align="center" />
-        <el-table-column prop="withoutLandCount" label="无土地有资格" width="120" align="center" />
+        <el-table-column prop="withLandCount" label="有土地退养" min-width="120" align="center" />
+        <el-table-column prop="withoutLandCount" label="无土地有资格" min-width="130" align="center" />
         <el-table-column
           v-if="isAgri || isAdminDistrict"
           prop="qualifiedCount"
           label="资格已核验/标注"
-          width="130"
+          min-width="140"
           align="center"
         />
         <el-table-column
           v-if="isAgri"
           label="预计金额(元)"
-          width="140"
+          min-width="140"
           align="right"
         >
           <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
         </el-table-column>
-        <el-table-column prop="alertCount" label="未关闭告警" width="100" align="center" />
+        <el-table-column prop="alertCount" label="未关闭告警" min-width="120" align="center" />
       </el-table>
       <TablePagination
         v-model:page="townSummaryPage"
@@ -48,22 +48,22 @@
 
     <!-- 镇经发 / 镇社区事务:本镇下属各村合计明细 -->
     <PagePanel v-if="isTownScope" title="本镇下属各村汇总">
-      <el-table :data="pagedVillageSummary" border stripe size="small">
-        <el-table-column prop="village" label="村居" width="110" />
-        <el-table-column prop="batchCount" label="批次数" width="88" align="center" />
-        <el-table-column prop="peopleCount" label="人员数" width="88" align="center" />
+      <el-table :data="pagedVillageSummary" border stripe size="small" style="width: 100%">
+        <el-table-column prop="village" label="村居" min-width="120" />
+        <el-table-column prop="batchCount" label="批次数" min-width="100" align="center" />
+        <el-table-column prop="peopleCount" label="人员数" min-width="100" align="center" />
         <el-table-column
           v-if="showMoney"
           label="预计金额(元)"
-          width="120"
+          min-width="140"
           align="right"
         >
           <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
         </el-table-column>
-        <el-table-column prop="withLandCount" label="有土地退养" width="110" align="center" />
-        <el-table-column prop="withoutLandCount" label="无土地有资格" width="120" align="center" />
-        <el-table-column prop="qualifiedCount" label="资格已核验" width="110" align="center" />
-        <el-table-column prop="alertCount" label="未关闭告警" width="100" align="center" />
+        <el-table-column prop="withLandCount" label="有土地退养" min-width="120" align="center" />
+        <el-table-column prop="withoutLandCount" label="无土地有资格" min-width="130" align="center" />
+        <el-table-column prop="qualifiedCount" label="资格已核验" min-width="120" align="center" />
+        <el-table-column prop="alertCount" label="未关闭告警" min-width="120" align="center" />
       </el-table>
       <TablePagination
         v-model:page="villageSummaryPage"
@@ -74,17 +74,17 @@
 
     <!-- 系统管理员:选定街镇后展示该镇下属各村汇总 -->
     <PagePanel v-if="isAdminDistrict && query.town" :title="`${query.town}下属各村汇总`">
-      <el-table :data="pagedAdminVillageSummary" border stripe size="small">
-        <el-table-column prop="village" label="村居" width="110" />
-        <el-table-column prop="batchCount" label="批次数" width="88" align="center" />
-        <el-table-column prop="peopleCount" label="人员数" width="88" align="center" />
-        <el-table-column label="预计金额(元)" width="120" align="right">
+      <el-table :data="pagedAdminVillageSummary" border stripe size="small" style="width: 100%">
+        <el-table-column prop="village" label="村居" min-width="120" />
+        <el-table-column prop="batchCount" label="批次数" min-width="100" align="center" />
+        <el-table-column prop="peopleCount" label="人员数" min-width="100" align="center" />
+        <el-table-column label="预计金额(元)" min-width="140" align="right">
           <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
         </el-table-column>
-        <el-table-column prop="withLandCount" label="有土地退养" width="110" align="center" />
-        <el-table-column prop="withoutLandCount" label="无土地有资格" width="120" align="center" />
-        <el-table-column prop="qualifiedCount" label="资格已核验" width="110" align="center" />
-        <el-table-column prop="alertCount" label="未关闭告警" width="100" align="center" />
+        <el-table-column prop="withLandCount" label="有土地退养" min-width="120" align="center" />
+        <el-table-column prop="withoutLandCount" label="无土地有资格" min-width="130" align="center" />
+        <el-table-column prop="qualifiedCount" label="资格已核验" min-width="120" align="center" />
+        <el-table-column prop="alertCount" label="未关闭告警" min-width="120" align="center" />
       </el-table>
       <TablePagination
         v-model:page="adminVillageSummaryPage"
@@ -221,22 +221,22 @@
         </el-form>
       </div>
 
-      <el-table :data="pagedBatches" border stripe size="small">
-        <el-table-column prop="batchNo" label="统一批次号" width="100" align="center" />
-        <el-table-column prop="month" label="月份" width="100" align="center" />
-        <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column prop="village" label="村居" width="90" />
-        <el-table-column prop="peopleCount" label="人数" width="72" align="center" />
-        <el-table-column v-if="showMoney" label="预计金额(元)" width="120" align="right">
+      <el-table :data="pagedBatches" border stripe size="small" style="width: 100%">
+        <el-table-column prop="batchNo" label="统一批次号" min-width="110" align="center" />
+        <el-table-column prop="month" label="月份" min-width="100" align="center" />
+        <el-table-column prop="town" label="街镇" min-width="110" />
+        <el-table-column prop="village" label="村居" min-width="110" />
+        <el-table-column prop="peopleCount" label="人数" min-width="90" align="center" />
+        <el-table-column v-if="showMoney" label="预计金额(元)" min-width="140" align="right">
           <template #default="{ row }">{{ formatAmount(row.totalAmount) }}</template>
         </el-table-column>
-        <el-table-column label="流转状态" width="130" align="center">
+        <el-table-column label="流转状态" min-width="140" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.stage" />
           </template>
         </el-table-column>
-        <el-table-column prop="submitter" label="提交人" width="90" />
-        <el-table-column prop="submittedAt" label="提交时间" min-width="140" />
+        <el-table-column prop="submitter" label="提交人" min-width="110" />
+        <el-table-column prop="submittedAt" label="提交时间" min-width="160" />
       </el-table>
       <TablePagination
         v-model:page="batchPage"
@@ -354,31 +354,31 @@
         </el-form>
       </div>
 
-      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small">
-        <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
-        <el-table-column prop="name" label="姓名" width="80" />
-        <el-table-column label="身份证" min-width="150">
+      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small" style="width: 100%">
+        <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
+        <el-table-column prop="name" label="姓名" min-width="90" />
+        <el-table-column label="身份证" min-width="160">
           <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
         </el-table-column>
-        <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column prop="town" label="街镇" min-width="100" />
+        <el-table-column prop="village" label="村居" min-width="100" />
         <el-table-column label="银行卡" min-width="150">
           <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
         </el-table-column>
         <el-table-column prop="bankName" label="开户行" min-width="120" show-overflow-tooltip />
-        <el-table-column prop="socialInsuranceCode" label="社会保险登记码" min-width="130" show-overflow-tooltip />
-        <el-table-column label="标准(元/月)" width="100" align="right">
+        <el-table-column prop="socialInsuranceCode" label="社会保险登记码" min-width="140" show-overflow-tooltip />
+        <el-table-column label="标准(元/月)" min-width="110" align="right">
           <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
         </el-table-column>
-        <el-table-column prop="startMonth" label="开始月份" width="100" align="center" />
-        <el-table-column label="状态" width="110" align="center">
+        <el-table-column prop="startMonth" label="开始月份" min-width="110" align="center" />
+        <el-table-column label="状态" min-width="120" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.status" />
           </template>
         </el-table-column>
-        <el-table-column prop="source" label="来源" width="110" show-overflow-tooltip />
-        <el-table-column prop="insuranceStatus" label="居保状态" width="100" />
-        <el-table-column label="退养资格" width="120" align="center">
+        <el-table-column prop="source" label="来源" min-width="110" show-overflow-tooltip />
+        <el-table-column prop="insuranceStatus" label="居保状态" min-width="110" />
+        <el-table-column label="退养资格" min-width="130" align="center">
           <template #default="{ row }">
             <el-tag
               v-if="row.retireQualifyType"
@@ -391,7 +391,7 @@
             <span v-else class="muted">—</span>
           </template>
         </el-table-column>
-        <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
       </el-table>
       <TablePagination
         v-model:page="peoplePage"
@@ -523,14 +523,14 @@
         </el-form>
       </div>
 
-      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small">
-        <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
-        <el-table-column prop="name" label="姓名" width="80" />
-        <el-table-column label="身份证" min-width="150">
+      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small" style="width: 100%">
+        <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
+        <el-table-column prop="name" label="姓名" min-width="90" />
+        <el-table-column label="身份证" min-width="160">
           <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
         </el-table-column>
-        <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column prop="town" label="街镇" min-width="100" />
+        <el-table-column prop="village" label="村居" min-width="100" />
         <el-table-column
           v-if="isHr || isAdminDistrict"
           label="银行卡"
@@ -549,13 +549,13 @@
           v-if="isHr || isAdminDistrict"
           prop="socialInsuranceCode"
           label="社会保险登记码"
-          min-width="130"
+          min-width="140"
           show-overflow-tooltip
         />
         <el-table-column
           v-if="isAgri || isAdminDistrict || isHr"
           label="退养资格"
-          width="120"
+          min-width="130"
           align="center"
         >
           <template #default="{ row }">
@@ -573,7 +573,7 @@
         <el-table-column
           v-if="showMoney"
           label="标准(元/月)"
-          width="100"
+          min-width="110"
           align="right"
         >
           <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
@@ -582,10 +582,10 @@
           v-if="isHr || isAdminDistrict || isAgri"
           prop="startMonth"
           label="开始月份"
-          width="100"
+          min-width="110"
           align="center"
         />
-        <el-table-column label="状态" width="110" align="center">
+        <el-table-column label="状态" min-width="120" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.status" />
           </template>
@@ -594,20 +594,20 @@
           v-if="isAgri || isAdminDistrict"
           prop="source"
           label="来源"
-          width="110"
+          min-width="110"
           show-overflow-tooltip
         />
         <el-table-column
           v-if="isHr || isAdminDistrict"
           prop="insuranceStatus"
           label="居保状态"
-          width="100"
+          min-width="110"
         />
         <el-table-column
           v-if="isAgri || isHr || isAdminDistrict"
           prop="remark"
           label="备注"
-          min-width="140"
+          min-width="160"
           show-overflow-tooltip
         />
       </el-table>
@@ -621,14 +621,14 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { computed, onMounted, reactive, ref, toRef, watch } from 'vue'
 import StatCards from '@/components/common/StatCards.vue'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import { fetchAlerts, fetchPeople, fetchReportBatches } from '@/api/biz'
-import { getTownList, getVillagesByTown } from '@/api/mock/data'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
@@ -855,13 +855,22 @@ const villageSummary = computed(() => {
   })
 })
 
+const effectiveTown = computed(() => (isDistrict.value ? query.town : lockedTown.value))
+
+/** 街镇/村居:联调走 /api/regions,不写死 */
+const { townOptions, villageOptions } = useDynamicRegionOptions(effectiveTown)
+const { villageOptions: townVillageOptions } = useDynamicRegionOptions(lockedTown)
+const { villageOptions: districtPeopleVillageOptions } = useDynamicRegionOptions(
+  toRef(peopleQuery, 'town'),
+)
+
 /** 系统管理员:选定街镇后按村汇总 */
 const adminVillageSummary = computed(() => {
   if (!isAdminDistrict.value || !query.town) return []
   const town = query.town
-  const villages = getVillagesByTown(town)
-  const list = villages.length
-    ? villages
+  const fromRegion = villageOptions.value
+  const list = fromRegion.length
+    ? fromRegion
     : [...new Set(people.value.filter((p) => p.town === town).map((p) => p.village))]
   return list.map((village) => {
     const villageBatches = batches.value.filter((b) => b.town === town && b.village === village)
@@ -881,35 +890,6 @@ const adminVillageSummary = computed(() => {
   })
 })
 
-const townOptions = computed(() => {
-  const fromConfig = getTownList()
-  if (fromConfig.length) return fromConfig
-  return [...new Set(batches.value.map((b) => b.town))]
-})
-const townVillageOptions = computed(() => {
-  const fromConfig = getVillagesByTown(lockedTown.value)
-  if (fromConfig.length) return fromConfig
-  return [...new Set(batches.value.map((b) => b.village))]
-})
-const villageOptions = computed(() => {
-  if (effectiveTown.value) {
-    const fromConfig = getVillagesByTown(effectiveTown.value)
-    if (fromConfig.length) return fromConfig
-  }
-  const list = batches.value.filter((b) => !effectiveTown.value || b.town === effectiveTown.value)
-  return [...new Set(list.map((b) => b.village))]
-})
-/** 区农委 / 区人社局人员查询:村居随所选街镇联动 */
-const districtPeopleVillageOptions = computed(() => {
-  if (peopleQuery.town) {
-    const fromConfig = getVillagesByTown(peopleQuery.town)
-    if (fromConfig.length) return fromConfig
-    return [
-      ...new Set(people.value.filter((p) => p.town === peopleQuery.town).map((p) => p.village)),
-    ]
-  }
-  return [...new Set(people.value.map((p) => p.village))]
-})
 const stageOptions = computed(() => [...new Set(batches.value.map((b) => b.stage))])
 const peopleStatusOptions = computed(() => [...new Set(people.value.map((p) => p.status))])
 const peopleSourceOptions = computed(() => [...new Set(people.value.map((p) => p.source))])
@@ -917,8 +897,6 @@ const insuranceStatusOptions = computed(() => [
   ...new Set(people.value.map((p) => p.insuranceStatus).filter(Boolean)),
 ])
 
-const effectiveTown = computed(() => (isDistrict.value ? query.town : lockedTown.value))
-
 function includesText(source: string | undefined, keyword: string): boolean {
   if (!keyword) return true
   return (source || '').includes(keyword)
@@ -1118,6 +1096,12 @@ watch(
 </script>
 
 <style scoped lang="less">
+.reports-view {
+  :deep(.el-table) {
+    width: 100%;
+  }
+}
+
 .reports-view__tip {
   margin: 0 0 10px;
   font-size: 12px;

+ 29 - 23
src/views/special/SpecialBusinessView.vue

@@ -420,8 +420,8 @@ import {
   triggerAdjustReissue,
   updateDeathStatus,
 } from '@/api/biz'
-import { getTownList, getVillagesByTown } from '@/api/mock/data'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import { formatAmount, maskIdCard } from '@/utils/mask'
@@ -451,7 +451,6 @@ const canOperate = computed(() =>
 )
 
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
-const allTownOptions = computed(() => getTownList())
 /** 系统管理员当前操作的街镇 */
 const adminTown = ref('')
 
@@ -460,11 +459,15 @@ const effectiveTown = computed(() =>
   isAdmin.value ? adminTown.value : userStore.roleInfo?.town || '',
 )
 
-/** 当前镇下属土地退养补助涉及村居(配置优先,并并入已有人员村居) */
+/** 街镇/村居:联调走 /api/regions */
+const { townOptions: allTownOptions, villageOptions: regionVillages, loadVillages } =
+  useDynamicRegionOptions(effectiveTown)
+
+/** 当前镇下属村居(区划接口优先,并并入已有人员村居) */
 const villageTabs = computed(() => {
   const town = effectiveTown.value
   if (!town) return [] as string[]
-  const fromMeta = getVillagesByTown(town)
+  const fromRegion = regionVillages.value
   const fromPeople = [
     ...new Set(
       people.value
@@ -473,9 +476,9 @@ const villageTabs = computed(() => {
         .filter(Boolean),
     ),
   ]
-  if (fromMeta.length) {
-    const metaSet = new Set(fromMeta)
-    return [...fromMeta, ...fromPeople.filter((v) => !metaSet.has(v))]
+  if (fromRegion.length) {
+    const metaSet = new Set(fromRegion)
+    return [...fromRegion, ...fromPeople.filter((v) => !metaSet.has(v))]
   }
   return fromPeople.sort()
 })
@@ -488,11 +491,11 @@ const villagePeople = computed(() =>
   ),
 )
 
-/** 调标补发:有名单的村居 Tab(保持镇配置顺序) */
+/** 调标补发:有名单的村居 Tab(保持区划顺序) */
 const adjustVillageTabs = computed(() => {
   const town = effectiveTown.value
   if (!town) return [] as string[]
-  const townsVillages = getVillagesByTown(town)
+  const townsVillages = regionVillages.value
   const fromRoster = [
     ...new Set(
       adjustRoster.value
@@ -587,23 +590,24 @@ const deathForm = reactive({
 /** 死亡录入弹窗:当前镇下属村居下拉选项 */
 const deathVillageOptions = computed(() => {
   const town = deathForm.town || effectiveTown.value
-  const fromMeta = getVillagesByTown(town)
-  if (fromMeta.length) return fromMeta
+  if (town && town === effectiveTown.value && regionVillages.value.length) {
+    return regionVillages.value
+  }
   return villageTabs.value
 })
 
 const specialForm = reactive({
   type: '暂停/停止',
   personName: '',
-  town: '叶榭镇',
-  village: '同建村',
+  town: '',
+  village: '',
   reason: '',
   amount: 0,
   heirName: '',
 })
 
 const reissueForm = reactive({
-  personId: 0,
+  personId: 0 as number | string,
   personName: '',
   town: '',
   village: '',
@@ -611,9 +615,10 @@ const reissueForm = reactive({
   reason: '',
 })
 
-function resetDeathForm() {
-  const town = effectiveTown.value || userStore.roleInfo?.town || '叶榭镇'
-  const villages = getVillagesByTown(town)
+async function resetDeathForm() {
+  const town = effectiveTown.value || userStore.roleInfo?.town || ''
+  if (town) await loadVillages(town)
+  const villages = regionVillages.value
   deathForm.personName = ''
   deathForm.idCard = ''
   deathForm.town = town
@@ -625,17 +630,17 @@ function resetDeathForm() {
   deathForm.heirContact = ''
 }
 
-function openDeathDialog() {
+async function openDeathDialog() {
   if (isAdmin.value && !adminTown.value) {
     ElMessage.warning('请先选择街镇')
     return
   }
-  resetDeathForm()
+  await resetDeathForm()
   deathDialogVisible.value = true
 }
 
 function onDeathDialogClosed() {
-  resetDeathForm()
+  void resetDeathForm()
 }
 
 watch(
@@ -689,15 +694,16 @@ function canResumePerson(row: Person) {
   return ['暂停', '停止'].includes(row.status)
 }
 
-function openSpecialDialog(type: string, person?: Person) {
+async function openSpecialDialog(type: string, person?: Person) {
   if (isAdmin.value && !person && !adminTown.value) {
     ElMessage.warning('请先选择街镇')
     return
   }
   specialForm.type = type
   specialForm.personName = person?.name || ''
-  specialForm.town = person?.town || effectiveTown.value || userStore.roleInfo?.town || '叶榭镇'
-  specialForm.village = person?.village || getVillagesByTown(specialForm.town)[0] || '同建村'
+  specialForm.town = person?.town || effectiveTown.value || userStore.roleInfo?.town || ''
+  if (specialForm.town) await loadVillages(specialForm.town)
+  specialForm.village = person?.village || regionVillages.value[0] || ''
   specialForm.reason = ''
   if (type === '丧葬补贴') specialForm.amount = 6000
   else if (type === '调标补发') specialForm.amount = 60

+ 2 - 2
src/views/status/StatusMaintenanceView.vue

@@ -174,15 +174,15 @@ import { ElMessage } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import { createStandard, fetchStandards, updateStandard } from '@/api/biz'
-import { getTownList } from '@/api/mock/data'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
 import type { SubsidyStandard } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
-const townOptions = computed(() => getTownList())
+const { townOptions } = useDynamicRegionOptions()
 const filterTown = ref('')
 
 const standards = ref<SubsidyStandard[]>([])

+ 1 - 1
tsconfig.app.json

@@ -4,7 +4,7 @@
     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
     "types": ["vite/client"],
     "allowArbitraryExtensions": true,
-    "ignoreDeprecations": "6.0",
+    "ignoreDeprecations": "5.0",
     "baseUrl": ".",
     "paths": {
       "@/*": ["src/*"]

+ 129 - 88
vite.config.ts

@@ -1,108 +1,149 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import { resolve } from 'path'
 import AutoImport from 'unplugin-auto-import/vite'
 import Components from 'unplugin-vue-components/vite'
 import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
 
+/**
+ * 开发代理后台环境(一键切换)
+ * - online:线上 Tomcat http://121.43.55.7:11091/sjnmtybt (Swagger)
+ * - local :本机 http://localhost:8088/sjnmtybt
+ *
+ * 切换方式(任选其一):
+ * 1) npm run dev          → 默认 online
+ * 2) npm run dev:local    → 本机后台
+ * 3) npm run dev:online   → 线上后台
+ * 4) 改下面 DEFAULT_API_ENV,或设环境变量 VITE_API_ENV=local|online
+ */
+const DEFAULT_API_ENV: 'online' | 'local' = 'online'
+
+const API_TARGETS = {
+  online: 'http://121.43.55.7:11091',
+  local: 'http://localhost:8088',
+} as const
+
 // https://vite.dev/config/
-export default defineConfig({
-  // 规避 Vite 8 开发客户端未注入 __BUNDLED_DEV__ 导致页面无法启动
-  define: {
-    __BUNDLED_DEV__: 'false',
-    __SERVER_FORWARD_CONSOLE__: 'false',
-  },
-  plugins: [
-    vue(),
-    AutoImport({
-      imports: ['vue', 'vue-router'],
-      resolvers: [ElementPlusResolver()],
-      dts: 'src/auto-imports.d.ts',
-    }),
-    // Element Plus 按需引入,减小首包体积
-    Components({
-      resolvers: [ElementPlusResolver()],
-      dts: 'src/components.d.ts',
-    }),
-  ],
-  resolve: {
-    alias: {
-      '@': resolve(__dirname, 'src'),
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), '')
+  const fromEnv = (env.VITE_API_ENV || process.env.VITE_API_ENV || '').toLowerCase()
+  // vite --mode local / online 优先;否则看 VITE_API_ENV;再否则 DEFAULT
+  const apiEnv: 'online' | 'local' =
+    mode === 'local' || mode === 'online'
+      ? mode
+      : fromEnv === 'local' || fromEnv === 'online'
+        ? fromEnv
+        : DEFAULT_API_ENV
+
+  const apiTarget = API_TARGETS[apiEnv]
+  console.log(`[vite] 开发代理后台 → ${apiEnv} (${apiTarget}/sjnmtybt)`)
+
+  return {
+    // 规避 Vite 8 开发客户端未注入 __BUNDLED_DEV__ 导致页面无法启动
+    define: {
+      __BUNDLED_DEV__: 'false',
+      __SERVER_FORWARD_CONSOLE__: 'false',
     },
-  },
-  css: {
-    preprocessorOptions: {
-      less: {
-        javascriptEnabled: true,
-        additionalData: (content: string, filename: string) => {
-          if (filename.replace(/\\/g, '/').includes('/styles/variables.less')) {
-            return content
-          }
-          return `@import "@/styles/variables.less";\n${content}`
-        },
+    plugins: [
+      vue(),
+      AutoImport({
+        imports: ['vue', 'vue-router'],
+        resolvers: [ElementPlusResolver()],
+        dts: 'src/auto-imports.d.ts',
+      }),
+      // Element Plus 按需引入,减小首包体积
+      Components({
+        resolvers: [ElementPlusResolver()],
+        dts: 'src/components.d.ts',
+      }),
+    ],
+    resolve: {
+      alias: {
+        '@': resolve(__dirname, 'src'),
       },
     },
-  },
-  build: {
-    // 单文件超过该体积时告警(单位 KB)
-    chunkSizeWarningLimit: 500,
-    cssCodeSplit: true,
-    rolldownOptions: {
-      output: {
-        // 将体积较大的依赖拆成独立 chunk,便于并行加载与缓存
-        codeSplitting: {
-          groups: [
-            {
-              name: 'vue-vendor',
-              test: /node_modules[\\/](vue|vue-router)([\\/]|$)/,
-            },
-            {
-              name: 'element-plus',
-              test: /node_modules[\\/]element-plus([\\/]|$)/,
-            },
-            {
-              name: 'element-icons',
-              test: /node_modules[\\/]@element-plus[\\/]icons-vue([\\/]|$)/,
-            },
-            {
-              name: 'axios',
-              test: /node_modules[\\/]axios([\\/]|$)/,
-            },
-            {
-              name: 'vendor',
-              test: /node_modules/,
-            },
-          ],
+    css: {
+      preprocessorOptions: {
+        less: {
+          javascriptEnabled: true,
+          additionalData: (content: string, filename: string) => {
+            if (filename.replace(/\\/g, '/').includes('/styles/variables.less')) {
+              return content
+            }
+            return `@import "@/styles/variables.less";\n${content}`
+          },
         },
       },
     },
-  },
-  // 排除原型目录,避免开发服务与打包过程触及该文件夹
-  server: {
-    host: true,
-    port: 3001,
-    fs: {
-      deny: ['**/松江区农民土地退养补助金发放管理平台-原型/**'],
+    build: {
+      // 单文件超过该体积时告警(单位 KB)
+      chunkSizeWarningLimit: 500,
+      cssCodeSplit: true,
+      // Vite 5 / Rollup:大依赖拆 chunk(兼容本机 Node 20.13)
+      rollupOptions: {
+        output: {
+          manualChunks(id) {
+            if (!id.includes('node_modules')) return
+            if (/[\\/](vue|vue-router)([\\/]|$)/.test(id)) return 'vue-vendor'
+            if (/[\\/]element-plus([\\/]|$)/.test(id)) return 'element-plus'
+            if (/[\\/]@element-plus[\\/]icons-vue([\\/]|$)/.test(id)) return 'element-icons'
+            if (/[\\/]axios([\\/]|$)/.test(id)) return 'axios'
+            return 'vendor'
+          },
+        },
+      },
     },
-    watch: {
-      ignored: ['**/松江区农民土地退养补助金发放管理平台-原型/**'],
+    // 预构建常用 Element Plus 组件,避免首次进入某菜单时触发「optimized dependencies changed」整页刷新,
+    // 刷新会清掉内存中的角色状态,路由守卫再把无权限页打回村合作社工作台。
+    optimizeDeps: {
+      include: [
+        'vue',
+        'vue-router',
+        'pinia',
+        'axios',
+        'element-plus/es',
+        'element-plus/es/locale/lang/zh-cn',
+        '@element-plus/icons-vue',
+        'echarts',
+      ],
     },
-    proxy: {
-      // 本系统业务接口
-      '/api': {
-        target: 'http://localhost:8080',
-        changeOrigin: true,
+    // 排除原型目录,避免开发服务与打包过程触及该文件夹
+    server: {
+      host: true,
+      port: 3001,
+      fs: {
+        deny: ['**/松江区农民土地退养补助金发放管理平台-原型/**'],
       },
-      // 本系统认证登录(oauth)
-      '/oauth': {
-        target: 'http://121.43.55.7:10086',
-        changeOrigin: true,
+      watch: {
+        ignored: ['**/松江区农民土地退养补助金发放管理平台-原型/**'],
       },
-      // 其他系统接口(对接时按实际地址修改 target)
-      '/external': {
-        target: 'http://121.43.55.7:10086',
-        changeOrigin: true,
+      proxy: {
+        // 业务 API:/api → {target}/sjnmtybt/api
+        '/api': {
+          target: apiTarget,
+          changeOrigin: true,
+          rewrite: (p) => `/sjnmtybt${p}`,
+        },
+        // 上传附件等相对路径(后端返回 /sjnmtybt/api/files/...)
+        '/sjnmtybt': {
+          target: apiTarget,
+          changeOrigin: true,
+        },
+        // 认证登录(oauth)— 与 DMS/OAuth 网关一致
+        '/oauth': {
+          target: 'http://121.43.55.7:2101',
+          changeOrigin: true,
+        },
+        '/proxy_oauth': {
+          target: 'http://121.43.55.7:2101',
+          changeOrigin: true,
+        },
+        // 其他系统接口
+        '/external': {
+          target: 'http://121.43.55.7:2101',
+          changeOrigin: true,
+        },
       },
     },
-  },
+  }
 })

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff