DESKTOP-6LTVLN7\Liumouren пре 2 месеци
родитељ
комит
ca3b34ec85

+ 2 - 2
README.md

@@ -21,7 +21,7 @@ npm run dev
 
 开发默认端口:`3001`。
 
-## 联调后端(sjnmtybt-server)
+## 联调后端(sj_nmtybt_server)
 
 1. 启动后端:`http://localhost:8088/sjnmtybt`
 2. `public/config/config.js` 设置 `useMock: false`(默认已关 mock)
@@ -45,7 +45,7 @@ public/
   data/                    # 静态数据
   js/                      # 静态脚本
 docs/
-  api-integration.md       # 与 sjnmtybt-server 联调说明
+  api-integration.md       # 与 sj_nmtybt_server 联调说明
 src/
   api/                     # 业务接口(biz.ts + sjnmtybt 适配层)
   utils/                   # request / 防抖 / 节流 / appConfig

+ 5 - 5
docs/api-integration.md

@@ -1,8 +1,8 @@
-# sj_tdtybz ↔ sjnmtybt-server 接口联调说明
+# sj_tdtybz ↔ sj_nmtybt_server 接口联调说明
 
 > 修订:2026-08-07  
 > 正式前端:`sj_tdtybz`(本仓库)  
-> 联调后端:`sjnmtybt-server`(Base:`http://localhost:8088/sjnmtybt`)  
+> 联调后端:`sj_nmtybt_server`(Base:`http://localhost:8088/sjnmtybt`)  
 > 旧演示前端 `sjnmtybt-web` **不再作为业务页面入口**,仅可作接口对照参考。
 
 ## 1. 目标与约束
@@ -16,7 +16,7 @@
 
 ## 2. 如何切换联调
 
-1. 启动后端:`sjnmtybt-server`(端口 **8088**,context-path **`/sjnmtybt`**)
+1. 启动后端:`sj_nmtybt_server`(端口 **8088**,context-path **`/sjnmtybt`**)
 2. 编辑 `public/config/config.js`:
    - `useMock: false`
    - `apiBase: '/api'`(默认)
@@ -152,7 +152,7 @@ vite.config.ts                 # /api 代理到 8088/sjnmtybt
 `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。
+演示账号(121 环境)见后端文档 `sj_nmtybt_server/docs/api-business-flow-guide.md` §0.0。
 
 ## 7. 联调检查清单(闭环)
 
@@ -178,4 +178,4 @@ vite.config.ts                 # /api 代理到 8088/sjnmtybt
 |------|------|
 | `sj_tdtybz` | **正式业务前端**(本联调对象) |
 | `sjnmtybt-web` | 历史联调页,**停用**;接口路径仍可对照 |
-| `sjnmtybt-server/docs/api-business-flow-guide.md` | 后端权威接口与状态机文档 |
+| `sj_nmtybt_server/docs/api-business-flow-guide.md` | 后端权威接口与状态机文档 |

+ 269 - 21
src/api/biz.ts

@@ -39,6 +39,16 @@ function delay<T>(data: T, ms = 200): Promise<T> {
   return new Promise((resolve) => setTimeout(() => resolve(data), ms))
 }
 
+function mockPaged<T>(list: T[], pageNum = 1, pageSize = 10) {
+  const start = (pageNum - 1) * pageSize
+  return delay({
+    list: list.slice(start, start + pageSize),
+    total: list.length,
+    pageNum,
+    pageSize,
+  })
+}
+
 /* ========== 工作台 ========== */
 export function fetchDashboard(params: {
   roleKey: string
@@ -68,9 +78,19 @@ export function fetchVillageBatches() {
 export function submitRetireQualify(
   personnelId: string,
   payload?: { opinion?: string; fileUrls?: string },
+  config?: { silent?: boolean },
 ) {
   if (useMock()) return delay({ ok: true })
-  return real.submitRetireQualify(personnelId, payload)
+  return real.submitRetireQualify(personnelId, payload, config)
+}
+
+export function submitRetireQualifyBatch(payload: {
+  personnelIds: string[]
+  opinion?: string
+  fileUrls?: string
+}) {
+  if (useMock()) return delay({ ok: true, successCount: payload.personnelIds.length, pendingCount: 0, failCount: 0, message: '' })
+  return real.submitRetireQualifyBatch(payload)
 }
 
 export function generateDistrictMonthBatch(
@@ -124,6 +144,23 @@ export function fetchPeoplePage(params?: real.PeoplePageParams) {
           (code === 'FUNERAL' && String(p.status).includes('死亡')),
       )
     }
+    if (params?.insuranceMatchResult) {
+      const code = String(params.insuranceMatchResult).toUpperCase()
+      list = list.filter(
+        (p) => String((p as Person).insuranceMatchResult || '').toUpperCase() === code,
+      )
+    }
+    if (params?.retireQualifyType) {
+      const code = String(params.retireQualifyType).toUpperCase()
+      list = list.filter((p) => {
+        const t = String((p as Person).retireQualifyType || '').toUpperCase()
+        if (code === 'UNSET') return !t || t === 'UNSET' || t === 'NONE' || t === 'CLEARED'
+        if (code === 'WITH_LAND') return t === 'WITH_LAND' || (p as Person).retireQualifyType === '有土地退养'
+        if (code === 'WITHOUT_LAND') return t === 'WITHOUT_LAND' || (p as Person).retireQualifyType === '无土地有资格'
+        if (code === 'INELIGIBLE') return t === 'INELIGIBLE' || (p as Person).retireQualifyType === '无资格'
+        return t === code
+      })
+    }
     const pageNum = params?.pageNum || 1
     const pageSize = params?.pageSize || 10
     const start = (pageNum - 1) * pageSize
@@ -295,7 +332,7 @@ export function fetchBatchMemberIds(batchId: string) {
 }
 
 /** 本批次人员明细(按发放明细,非全村人员库) */
-export function fetchBatchMemberPeople(
+export function fetchBatchMemberPeoplePage(
   batchId: string,
   opts?: {
     batchNo?: string
@@ -303,11 +340,13 @@ export function fetchBatchMemberPeople(
     village?: string
     pruneDefaults?: boolean
     parentBatchId?: string
+    pageNum?: number
+    pageSize?: number
   },
 ) {
   if (useMock()) {
     const batch = mockVillageBatches.find((b) => b.id === batchId)
-    if (!batch) return delay([] as BatchMemberPerson[])
+    if (!batch) return delay({ list: [] as BatchMemberPerson[], total: 0, pageNum: 1, pageSize: 10 })
     const list = mockPeople
       .filter(
         (p) =>
@@ -317,7 +356,25 @@ export function fetchBatchMemberPeople(
           !['暂停', '停止', '死亡丧葬', '已归档'].includes(p.status),
       )
       .map((p) => mockAsBatchMember(p))
-    return delay(list)
+    return mockPaged(list, opts?.pageNum, opts?.pageSize)
+  }
+  return real.fetchBatchMemberPeoplePage(batchId, opts)
+}
+
+export function fetchBatchMemberPeople(
+  batchId: string,
+  opts?: {
+    batchNo?: string
+    town?: string
+    village?: string
+    pruneDefaults?: boolean
+    parentBatchId?: string
+    pageNum?: number
+    pageSize?: number
+  },
+) {
+  if (useMock()) {
+    return fetchBatchMemberPeoplePage(batchId, opts).then((p) => p.list)
   }
   return real.fetchBatchMemberPeople(batchId, opts)
 }
@@ -532,7 +589,13 @@ export function addBatchProcessNote(payload: {
 }
 
 /* ========== 居保匹配 ========== */
-export function fetchInsuranceBatches(params?: { tab?: 'todo' | 'history' }) {
+export function fetchInsuranceBatchesPage(params?: {
+  tab?: 'todo' | 'history'
+  batchNo?: string
+  stage?: string
+  pageNum?: number
+  pageSize?: number
+}) {
   if (useMock()) {
     const todo = new Set(['居保状态匹配', '比对结果已生成'])
     const history = new Set([
@@ -543,24 +606,50 @@ export function fetchInsuranceBatches(params?: { tab?: 'todo' | 'history' }) {
       '已归档',
     ])
     const stages = params?.tab === 'history' ? history : todo
-    return delay(mockVillageBatches.filter((b) => stages.has(b.stage)))
+    let list = mockVillageBatches.filter((b) => stages.has(b.stage))
+    if (params?.batchNo) list = list.filter((b) => b.batchNo.includes(params.batchNo!))
+    if (params?.stage) list = list.filter((b) => b.stage === params.stage)
+    return mockPaged(list, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchInsuranceBatchesPage(params)
+}
+
+export function fetchInsuranceBatches(params?: { tab?: 'todo' | 'history' }) {
+  if (useMock()) {
+    return fetchInsuranceBatchesPage(params).then((p) => p.list)
   }
   return real.fetchInsuranceBatches(params)
 }
 
 /** 查询居保匹配结果;可按批次号 / 街镇 / 村居过滤 */
-export function fetchInsuranceResults(params?: {
+export function fetchInsuranceResultsPage(params?: {
   batchId?: string
   batchNo?: string
   town?: string
   village?: string
+  pageNum?: number
+  pageSize?: number
 }) {
   if (useMock()) {
     let list = [...mockPeople]
     if (params?.batchNo) list = list.filter((p) => p.batchNo === params.batchNo)
     if (params?.town) list = list.filter((p) => p.town === params.town)
     if (params?.village) list = list.filter((p) => p.village === params.village)
-    return delay(list)
+    return mockPaged(list, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchInsuranceResultsPage(params)
+}
+
+export function fetchInsuranceResults(params?: {
+  batchId?: string
+  batchNo?: string
+  town?: string
+  village?: string
+  pageNum?: number
+  pageSize?: number
+}) {
+  if (useMock()) {
+    return fetchInsuranceResultsPage(params).then((p) => p.list)
   }
   return real.fetchInsuranceResults(params)
 }
@@ -684,10 +773,14 @@ export function fetchApprovals(params?: {
   node?: string
   /** 街镇(镇经发 / 分管领导仅看本镇) */
   town?: string
+  village?: string
   /** 统一批次号,如 202608 */
   batchNo?: string
   /** 业务类型包含关键字,如「月度批次」 */
   bizTypeIncludes?: string
+  status?: string
+  pageNum?: number
+  pageSize?: number
 }) {
   if (useMock()) {
     let list = [...mockApprovals]
@@ -697,11 +790,13 @@ export function fetchApprovals(params?: {
     }
     if (params?.node) list = list.filter((a) => a.node === params.node)
     if (params?.town) list = list.filter((a) => a.town === params.town)
+    if (params?.village) list = list.filter((a) => a.village === params.village)
     if (params?.batchNo) list = list.filter((a) => a.batchNo === params.batchNo)
+    if (params?.status) list = list.filter((a) => a.status === params.status)
     if (params?.bizTypeIncludes) {
       list = list.filter((a) => a.bizType.includes(params.bizTypeIncludes!))
     }
-    return delay(list)
+    return mockPaged(list, params?.pageNum, params?.pageSize)
   }
   return real.fetchApprovals(params)
 }
@@ -784,6 +879,21 @@ export function decideApproval(payload: {
   return real.decideApproval(payload)
 }
 
+export function decideApprovals(payload: {
+  ids: string[]
+  pass: boolean
+  opinion?: string
+  fileUrls?: string
+  retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
+}) {
+  if (useMock()) {
+    return Promise.all(payload.ids.map((id) => decideApproval({ ...payload, id }))).then(() => ({
+      ok: true,
+    }))
+  }
+  return real.decideApprovals(payload)
+}
+
 /* ========== 人员状态维护 ========== */
 export function fetchStatusBatches() {
   if (useMock()) {
@@ -852,6 +962,7 @@ export function updateStandard(payload: {
   date: string
   reason: string
   rawId?: string
+  town?: string
 }) {
   if (useMock()) {
     const row = mockStandards.find((s) => s.id === payload.id)
@@ -925,12 +1036,25 @@ export function applyPersonStatusAction(payload: {
 }
 
 /* ========== 特殊业务 ========== */
+export function fetchSpecialsPage(params?: {
+  town?: string
+  bizType?: string
+  approveStatus?: string
+  pageNum?: number
+  pageSize?: number
+}) {
+  if (useMock()) {
+    let list = params?.town ? mockSpecials.filter((s) => s.town === params.town) : [...mockSpecials]
+    if (params?.bizType === 'FUNERAL') list = list.filter((s) => s.type === '丧葬补贴')
+    if (params?.bizType === 'REFUND') list = list.filter((s) => s.type === '多发退款')
+    return mockPaged(list, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchSpecialsPage(params)
+}
+
 export function fetchSpecials(params?: { town?: string }) {
   if (useMock()) {
-    const list = params?.town
-      ? mockSpecials.filter((s) => s.town === params.town)
-      : [...mockSpecials]
-    return delay(list)
+    return fetchSpecialsPage(params).then((p) => p.list)
   }
   return real.fetchSpecials(params)
 }
@@ -944,12 +1068,17 @@ export function fetchSpecialBizPerson(personnelId: string) {
   return real.fetchSpecialBizPerson(personnelId)
 }
 
-export function fetchDeaths(params?: { town?: string }) {
+export function fetchDeathsPage(params?: { town?: string; pageNum?: number; pageSize?: number }) {
   if (useMock()) {
-    const list = params?.town
-      ? mockDeaths.filter((d) => d.town === params.town)
-      : [...mockDeaths]
-    return delay(list)
+    const list = params?.town ? mockDeaths.filter((d) => d.town === params.town) : [...mockDeaths]
+    return mockPaged(list, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchDeathsPage(params)
+}
+
+export function fetchDeaths(params?: { town?: string; pageNum?: number; pageSize?: number }) {
+  if (useMock()) {
+    return fetchDeathsPage(params).then((p) => p.list)
   }
   return real.fetchDeaths(params)
 }
@@ -1275,6 +1404,26 @@ function buildVillageMonthlyItem(b: VillageBatch): VillageMonthlyBatch {
 }
 
 /** 本镇各村月补贴清单批次(待提交/待审核/已审核/已出盘/已回盘) */
+export function fetchVillageMonthlyBatchesPage(params?: {
+  town?: string
+  pageNum?: number
+  pageSize?: number
+}) {
+  if (useMock()) {
+    let list = mockVillageBatches.filter((b) => MONTHLY_LIST_STAGES.includes(b.stage))
+    if (params?.town) list = list.filter((b) => b.town === params.town)
+    const items = list.map(buildVillageMonthlyItem)
+    items.sort((a, b) => {
+      const ta = a.submittedAt || ''
+      const tb = b.submittedAt || ''
+      if (ta !== tb) return tb.localeCompare(ta)
+      return a.village.localeCompare(b.village)
+    })
+    return mockPaged(items, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchVillageMonthlyBatchesPage(params)
+}
+
 export function fetchVillageMonthlyBatches(town?: string) {
   if (useMock()) {
     let list = mockVillageBatches.filter((b) => MONTHLY_LIST_STAGES.includes(b.stage))
@@ -1450,10 +1599,21 @@ export function confirmVillageMonthlyReturn(payload: {
 
 /* ========== 回盘信息确认 ========== */
 /** 回盘批次列表(按批次号从新到旧) */
+export function fetchDisksPage(params?: { town?: string; pageNum?: number; pageSize?: number }) {
+  if (useMock()) {
+    const list = params?.town ? mockDisks.filter((d) => d.town === params.town) : [...mockDisks]
+    return mockPaged(
+      [...list].sort((a, b) => b.batchNo.localeCompare(a.batchNo)),
+      params?.pageNum,
+      params?.pageSize,
+    )
+  }
+  return real.fetchDisksPage(params)
+}
+
 export function fetchDisks(town?: string) {
   if (useMock()) {
-    const list = town ? mockDisks.filter((d) => d.town === town) : [...mockDisks]
-    return delay([...list].sort((a, b) => b.batchNo.localeCompare(a.batchNo)))
+    return fetchDisksPage({ town }).then((p) => p.list)
   }
   return real.fetchDisks(town)
 }
@@ -1691,6 +1851,21 @@ function mTownVillageMatch(
 }
 
 /* ========== 告警 ========== */
+export function fetchAlertsPage(params?: {
+  town?: string
+  village?: string
+  pageNum?: number
+  pageSize?: number
+}) {
+  if (useMock()) {
+    let list = [...mockAlerts]
+    if (params?.town) list = list.filter((a) => a.town === params.town)
+    if (params?.village) list = list.filter((a) => a.village === params.village)
+    return mockPaged(list, params?.pageNum, params?.pageSize)
+  }
+  return real.fetchAlertsPage(params)
+}
+
 export function fetchAlerts() {
   if (useMock()) return delay([...mockAlerts])
   return real.fetchAlerts()
@@ -1773,6 +1948,11 @@ export function handleAlert(payload: HandleAlertPayload | string, result?: strin
         if (data.town) person.town = data.town
         if (data.village) person.village = data.village
         if (a.type === '民政死亡比对') person.status = '死亡丧葬'
+        if (String(a.type || '').includes('退养资格驳回')) {
+          person.status = '待提交资格'
+          person.bizStatus = 'DRAFT'
+          person.retireQualifyType = undefined
+        }
       }
 
       const role =
@@ -1799,6 +1979,27 @@ export function fetchReportBatches() {
   return real.fetchReportBatches()
 }
 
+export function fetchWorkbenchStats(params?: {
+  payMonth?: string
+  town?: string
+  village?: string
+  months?: number
+  roleKey?: string
+}) {
+  if (useMock()) {
+    return delay({
+      personnelCount: mockPeople.length,
+      withLandCount: mockPeople.filter((p) => p.retireQualifyType === '有土地退养').length,
+      withoutLandCount: mockPeople.filter((p) => p.retireQualifyType === '无土地有资格').length,
+      qualifiedCount: mockPeople.length,
+      monthAmount: 0,
+      townQualify: [],
+      villageCharts: [],
+    })
+  }
+  return real.fetchWorkbenchStats(params)
+}
+
 /* ========== 系统管理 ========== */
 export function fetchAccounts() {
   if (useMock()) return delay([...mockAccounts])
@@ -1810,6 +2011,37 @@ export function fetchOpLogs() {
   return real.fetchOpLogs()
 }
 
+export function fetchOpLogsPage(params?: {
+  pageNum?: number
+  pageSize?: number
+  operator?: string
+  operatorRole?: string
+  bizType?: string
+  action?: string
+  result?: string
+  operateTimeFrom?: number
+  operateTimeTo?: number
+}) {
+  const pageNum = params?.pageNum || 1
+  const pageSize = params?.pageSize || 10
+  if (useMock()) {
+    let list = [...mockLogs]
+    const op = params?.operator?.trim()
+    if (op) list = list.filter((r) => r.user.includes(op) || r.role.includes(op))
+    if (params?.result) list = list.filter((r) => r.result.includes(params.result!))
+    if (params?.bizType) list = list.filter((r) => r.module.includes(params.bizType!))
+    if (params?.action) list = list.filter((r) => r.action.includes(params.action!))
+    const start = (pageNum - 1) * pageSize
+    return delay({
+      list: list.slice(start, start + pageSize),
+      total: list.length,
+      pageNum,
+      pageSize,
+    })
+  }
+  return real.fetchOpLogsPage(params)
+}
+
 export function factoryResetBusinessData() {
   if (useMock()) return delay({ ok: true as const, destroyedTotal: 0, detail: {}, message: 'mock 跳过' })
   return real.factoryResetBusinessData()
@@ -1849,8 +2081,10 @@ export function pageAdminBatches(params?: {
   villageId?: string
   batchLevel?: string
   status?: string
+  pageNum?: number
+  pageSize?: number
 }) {
-  if (useMock()) return delay([])
+  if (useMock()) return delay({ list: [], total: 0, pageNum: 1, pageSize: 10 })
   return real.pageAdminBatches(params)
 }
 
@@ -1869,6 +2103,11 @@ export function deletePersonnelBatchAdmin(ids: string[]) {
   return real.deletePersonnelBatchAdmin(ids)
 }
 
+export function clearAllPersonnelAdmin() {
+  if (useMock()) return delay({ ok: true as const, successCount: 0, failCount: 0, message: '已清空 0 人' })
+  return real.clearAllPersonnelAdmin()
+}
+
 export function updatePersonnelStatusAdmin(payload: {
   personnelId: string
   bizStatus: string
@@ -1980,6 +2219,15 @@ export function fetchLoginLogs(limit = 100) {
   return real.fetchLoginLogs(limit)
 }
 
+export function fetchLoginLogsPage(params?: { pageNum?: number; pageSize?: number }) {
+  const pageNum = params?.pageNum || 1
+  const pageSize = params?.pageSize || 10
+  if (useMock()) {
+    return delay({ list: [] as OpLog[], total: 0, pageNum, pageSize })
+  }
+  return real.fetchLoginLogsPage(params)
+}
+
 export function recordLoginAudit(payload: {
   username?: string
   roleKey?: string

+ 15 - 5
src/api/sjnmtybt/mappers.ts

@@ -14,7 +14,7 @@ import type {
   VillageBatch,
   VillageMonthlyBatch,
 } from '@/types/biz'
-import { regionName } from './regionStore'
+import { regionDisplay, regionName } from './regionStore'
 
 /** 后端批次 status → 前端 VillageBatch.stage(页面流程文案) */
 const BATCH_STATUS_TO_STAGE: Record<string, string> = {
@@ -213,8 +213,8 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     batchNo: batchNoHint || '',
     name: String(raw.name || ''),
     idCard: String(raw.idNumber || ''),
-    town: regionName(String(raw.townId || '')),
-    village: regionName(String(raw.villageId || '')),
+    town: regionDisplay(String(raw.townId || ''), raw.townName != null ? String(raw.townName) : ''),
+    village: regionDisplay(String(raw.villageId || ''), raw.villageName != null ? String(raw.villageName) : ''),
     bankCard: String(raw.bankCardNumber || ''),
     bankName: String(raw.bankName || ''),
     socialInsuranceCode: raw.socialInsuranceCode ? String(raw.socialInsuranceCode) : undefined,
@@ -401,6 +401,7 @@ export function mapProcessLog(raw: Record<string, unknown>): ProcessLogItem {
     fileUrls: stripProcessFileUrls(raw.fileUrls ? String(raw.fileUrls) : undefined),
     remarks: raw.remarks ? String(raw.remarks) : undefined,
     bizType,
+    personnelId: raw.personnelId ? String(raw.personnelId) : undefined,
   }
 }
 
@@ -510,6 +511,15 @@ export function mapAlert(raw: Record<string, unknown>): AlertItem {
   const hs = String(raw.handleStatus || '')
   const level = String(raw.level || '')
   const typeCode = String(raw.type || '')
+  const fileUrls = raw.fileUrls ? String(raw.fileUrls) : undefined
+  const lifecycle = mapAlertLifecycle(raw.lifecycle)
+  if (fileUrls && lifecycle.length) {
+    const close = [...lifecycle].reverse().find(
+      (i) => i.action === 'CLOSE' || String(i.actionLabel || '').includes('关闭'),
+    )
+    if (close && !close.fileUrls) close.fileUrls = fileUrls
+    else if (!close && !lifecycle[0].fileUrls) lifecycle[0].fileUrls = fileUrls
+  }
   return {
     id: String(raw.id || ''),
     batchNo: alertBatchLabel(raw),
@@ -524,11 +534,11 @@ export function mapAlert(raw: Record<string, unknown>): AlertItem {
     result: raw.handleResult ? String(raw.handleResult) : undefined,
     createdAt: fmtTime(raw.createTime as number),
     failReason: raw.failReasonCode ? String(raw.failReasonCode) : undefined,
-    lifecycle: mapAlertLifecycle(raw.lifecycle),
+    lifecycle,
     handler: raw.handler ? String(raw.handler) : undefined,
     handleTime: fmtTime(raw.handleTime as number) || undefined,
     remarks: raw.remarks ? String(raw.remarks) : undefined,
-    fileUrls: raw.fileUrls ? String(raw.fileUrls) : undefined,
+    fileUrls,
   }
 }
 

Разлика између датотеке није приказан због своје велике величине
+ 458 - 274
src/api/sjnmtybt/realBiz.ts


+ 53 - 5
src/api/sjnmtybt/regionStore.ts

@@ -39,10 +39,12 @@ const nameToVillageId = new Map<string, string>()
 const idToName = new Map<string, string>()
 
 let townsLoaded = false
+let allVillagesLoaded = false
 
 /** 区划增删改后清空前端缓存,避免下拉仍是旧村居 */
 export function invalidateRegionCache() {
   townsLoaded = false
+  allVillagesLoaded = false
   towns.splice(0, towns.length)
   villagesByTown.clear()
   nameToTownId.clear()
@@ -52,16 +54,33 @@ export function invalidateRegionCache() {
 
 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)
+  const display = looksLikeRegionCode(name) ? FALLBACK_NAME[row.id] || name : name
+  idToName.set(row.id, display)
+  if (row.code) idToName.set(row.code, display)
   if (row.level === 'TOWN' || row.id.startsWith('T_')) {
-    nameToTownId.set(name, row.id)
+    nameToTownId.set(display, row.id)
   }
 }
 
+function looksLikeRegionCode(id?: string | null): boolean {
+  const s = String(id || '').trim()
+  return /^(D_|T_|V_)[A-Z0-9_]+$/i.test(s) || /^\d{9,}$/.test(s)
+}
+
 export function regionName(id?: string | null): string {
   if (!id) return ''
-  return idToName.get(id) || FALLBACK_NAME[id] || id
+  const hit = idToName.get(id) || FALLBACK_NAME[id]
+  if (hit && !looksLikeRegionCode(hit)) return hit
+  return FALLBACK_NAME[id] || id
+}
+
+/** 列表展示:接口中文名优先,其次区划字典,最后才回退 ID */
+export function regionDisplay(id?: string | null, apiName?: string | null): string {
+  const fromApi = String(apiName || '').trim()
+  if (fromApi && !looksLikeRegionCode(fromApi)) return fromApi
+  const fromDict = regionName(id)
+  if (fromDict && !looksLikeRegionCode(fromDict)) return fromDict
+  return fromApi || fromDict || ''
 }
 
 export function townIdOf(nameOrId?: string): string {
@@ -108,7 +127,15 @@ 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)!
+  if (villagesByTown.has(tid) && (villagesByTown.get(tid) || []).length) {
+    return villagesByTown.get(tid)!
+  }
+  if (!allVillagesLoaded) {
+    await ensureAllVillages()
+    if (villagesByTown.has(tid) && (villagesByTown.get(tid) || []).length) {
+      return villagesByTown.get(tid)!
+    }
+  }
   const data = await get<RegionRow[]>('/regions', { level: 'VILLAGE', parentId: tid })
   const list = data || []
   villagesByTown.set(tid, list)
@@ -120,6 +147,27 @@ export async function ensureVillages(townNameOrId: string): Promise<RegionRow[]>
   return list
 }
 
+/** 一次拉全区村居进字典,避免人员列表只加载了当前街镇时其它村露出 V_YX_003 */
+export async function ensureAllVillages(): Promise<void> {
+  await ensureTowns()
+  if (allVillagesLoaded) return
+  const data = await get<RegionRow[]>('/regions', { level: 'VILLAGE' })
+  for (const v of data || []) {
+    if (!v || !v.id) continue
+    remember(v)
+    const tid = String(v.parentId || '')
+    if (!tid) continue
+    const bucket = villagesByTown.get(tid) || []
+    if (!bucket.some((x) => x.id === v.id)) {
+      bucket.push(v)
+      villagesByTown.set(tid, bucket)
+    }
+    const name = regionName(v.id)
+    if (name) nameToVillageId.set(`${tid}|${name}`, v.id)
+  }
+  allVillagesLoaded = true
+}
+
 export async function resolveScopeIds(town?: string, village?: string) {
   await ensureTowns()
   const townId = townIdOf(town)

+ 1 - 4
src/components.d.ts

@@ -1,11 +1,8 @@
 /* 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 */

+ 91 - 24
src/components/biz/BatchDetailDrawer.vue

@@ -19,9 +19,11 @@
             <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="街镇">{{ displayTown }}</el-descriptions-item>
+            <el-descriptions-item label="村居">{{ displayVillage }}</el-descriptions-item>
+            <el-descriptions-item label="人数">
+              {{ peopleLoading ? (batch.peopleCount ?? '—') : peopleTotal }}
+            </el-descriptions-item>
             <el-descriptions-item label="合计金额">
               {{ formatAmount(batch.totalAmount) }}
             </el-descriptions-item>
@@ -94,7 +96,7 @@
           <p class="batch-drawer__sub">
             本批次人员明细
             <span class="batch-drawer__sub-amount">
-              共 {{ people.length }} 人,应发合计 {{ formatAmount(peopleAmountSummary.payable) }} 元
+              共 {{ peopleTotal }} 人,应发合计 {{ formatAmount(peopleAmountSummary.payable) }} 元
               <template v-if="isAdjustBatch">
                 (调标差额批:应发=补发合计 {{ formatAmount(peopleAmountSummary.supplement) }})
               </template>
@@ -329,7 +331,7 @@ import {
   addBatchProcessNote,
   exportProcessTimeline,
   fetchBatchDetail,
-  fetchBatchMemberPeople,
+  fetchBatchMemberPeoplePage,
   fetchBatchOverview,
   fetchBatchProgress,
   fetchBatchTimeline,
@@ -337,11 +339,12 @@ import {
 } from '@/api/biz'
 import { actionLabel } from '@/constants/batchActions'
 import { resultLabelOf } from '@/utils/statusLabels'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
-import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
+import { paymentSummaryFromApi, sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { useUserStore } from '@/stores/user'
 import type {
   BatchFlowOverview,
   BatchMemberPerson,
@@ -349,6 +352,8 @@ import type {
   VillageBatch,
 } from '@/types/biz'
 
+const userStore = useUserStore()
+
 const props = withDefaults(
   defineProps<{
     modelValue: boolean
@@ -373,6 +378,7 @@ const batch = ref<VillageBatch | null>(null)
 const overview = ref<BatchFlowOverview | null>(null)
 const logs = ref<ProcessLogItem[]>([])
 const people = ref<BatchMemberPerson[]>([])
+const paymentSummary = ref<ReturnType<typeof paymentSummaryFromApi>>(null)
 const progressSteps = ref<Array<Record<string, unknown>>>([])
 const progressActive = computed(() => {
   const idx = progressSteps.value.findIndex((s) => String(s.state) === 'active')
@@ -408,9 +414,23 @@ const {
   page: peoplePage,
   pageSize: peoplePageSize,
   total: peopleTotal,
-  pagedList: pagedPeople,
-  resetPage: resetPeoplePage,
-} = useClientPagination(people, { pageSize: 10 })
+  list: pagedPeople,
+  resetAndLoad: loadPeoplePage,
+} = useServerPagination<BatchMemberPerson>(async (pageNum, pageSize) => {
+  const b = batch.value
+  if (!b?.id) return { list: [], total: 0 }
+  const page = await fetchBatchMemberPeoplePage(b.parentBatchId || b.id, {
+    batchNo: b.batchNo,
+    town: scopedTownForBatch(b) || undefined,
+    village: scopedVillageForBatch(b) || undefined,
+    pruneDefaults: b.stage === '人员新增录入' || b.stage === '退回修改',
+    parentBatchId: b.parentBatchId,
+    pageNum,
+    pageSize,
+  })
+  paymentSummary.value = paymentSummaryFromApi(page.summary)
+  return page
+})
 
 const isAdjustBatch = computed(() =>
   isYearlyAdjustBatchLike({
@@ -423,16 +443,33 @@ const isAdjustBatch = computed(() =>
   }),
 )
 
-const peopleAmountSummary = computed(() =>
-  sumBatchMemberAmounts(people.value, { adjustOnly: isAdjustBatch.value }),
+const peopleAmountSummary = computed(() => {
+  if (paymentSummary.value) return paymentSummary.value
+  const b = batch.value
+  if (b) {
+    return {
+      count: peopleTotal.value || Number(b.peopleCount || 0),
+      monthly: Number(b.normalAmount || 0),
+      oneTime: 0,
+      supplement: Number(b.supplementAmount || 0),
+      funeral: 0,
+      deduct: Number(b.deductAmount || 0),
+      payable: Number(b.totalAmount || 0),
+    }
+  }
+  return sumBatchMemberAmounts(pagedPeople.value, { adjustOnly: isAdjustBatch.value })
+})
+
+const pageAmountSummary = computed(() =>
+  sumBatchMemberAmounts(pagedPeople.value, { adjustOnly: isAdjustBatch.value }),
 )
 
 function peopleTableSummary(param: {
   columns: Array<{ label?: string; property?: string }>
 }) {
-  const sum = peopleAmountSummary.value
+  const sum = pageAmountSummary.value
   return param.columns.map((col, index) => {
-    if (index === 0) return '合计'
+    if (index === 0) return '本页合计'
     const label = String(col.label || col.property || '')
     if (label.includes('月标') || label.includes('标准')) return formatAmount(sum.monthly)
     if (label.includes('补发')) return formatAmount(sum.supplement)
@@ -472,18 +509,46 @@ function resultType(result?: string): 'success' | 'danger' | 'primary' | 'warnin
   return 'primary'
 }
 
-async function loadPeopleForBatch(b: VillageBatch) {
+function isDistrictBatch(b: VillageBatch) {
+  return b.batchLevel === 'DISTRICT' || b.village === '全区' || (!b.town && !b.village)
+}
+
+function scopedTownForBatch(b: VillageBatch) {
+  const role = userStore.roleInfo
+  if (isDistrictBatch(b) && role && !role.allData && role.town) {
+    return role.town
+  }
+  if (b.town && b.town !== '全区' && b.town !== '本镇') return b.town
+  if (role && !role.allData) return role.town || ''
+  return ''
+}
+
+function scopedVillageForBatch(b: VillageBatch) {
+  const v = b.village
+  if (!v || v === '全区' || v === '本镇') return userStore.roleInfo?.village || ''
+  return v
+}
+
+const displayTown = computed(() => {
+  const b = batch.value
+  if (!b) return '-'
+  return scopedTownForBatch(b) || b.town || '全区'
+})
+
+const displayVillage = computed(() => {
+  const b = batch.value
+  if (!b) return '-'
+  const v = scopedVillageForBatch(b)
+  if (v) return v
+  if (isDistrictBatch(b) && scopedTownForBatch(b)) return '本镇'
+  return b.village || '-'
+})
+
+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()
+    await loadPeoplePage()
+    people.value = pagedPeople.value
   } finally {
     peopleLoading.value = false
   }
@@ -527,6 +592,7 @@ async function load() {
   logs.value = []
   flowLoadedFor.value = ''
   people.value = []
+  paymentSummary.value = null
   progressSteps.value = []
   try {
     const [b, o, steps] = await Promise.all([
@@ -583,6 +649,7 @@ function onClosed() {
   overview.value = null
   logs.value = []
   people.value = []
+  paymentSummary.value = null
   noteOpinion.value = ''
   noteFileList.value = []
   noteRawFiles.value = []

+ 24 - 7
src/composables/useServerPagination.ts

@@ -1,4 +1,4 @@
-import { ref, watch, type Ref } from 'vue'
+import { nextTick, ref, watch, type Ref } from 'vue'
 
 export type ServerPageFetcher<T> = (pageNum: number, pageSize: number) => Promise<{
   list: T[]
@@ -7,10 +7,11 @@ export type ServerPageFetcher<T> = (pageNum: number, pageSize: number) => Promis
 
 /**
  * 服务端分页:翻页/改 pageSize 时重新请求(不自动 immediate,由页面 resetAndLoad 触发)。
+ * 同一轮同时变页码和每页条数(改 pageSize 会回到第 1 页)只请求一次。
  */
 export function useServerPagination<T>(
   fetcher: ServerPageFetcher<T>,
-  options?: { pageSize?: number },
+  options?: { pageSize?: number; immediate?: boolean },
 ) {
   const page = ref(1)
   const pageSize = ref(options?.pageSize ?? 10)
@@ -18,6 +19,7 @@ export function useServerPagination<T>(
   const list = ref<T[]>([]) as Ref<T[]>
   const loading = ref(false)
   let skipWatch = false
+  let loadGen = 0
 
   async function load() {
     loading.value = true
@@ -30,24 +32,39 @@ export function useServerPagination<T>(
     }
   }
 
+  function scheduleLoad() {
+    const gen = ++loadGen
+    void nextTick(() => {
+      if (gen !== loadGen) return
+      void load()
+    })
+  }
+
   async function resetAndLoad() {
     skipWatch = true
     page.value = 1
+    await nextTick()
     skipWatch = false
     await load()
   }
 
   watch(page, () => {
     if (skipWatch) return
-    void load()
+    scheduleLoad()
   })
   watch(pageSize, () => {
-    skipWatch = true
-    page.value = 1
-    skipWatch = false
-    void load()
+    if (page.value !== 1) {
+      skipWatch = true
+      page.value = 1
+      skipWatch = false
+    }
+    scheduleLoad()
   })
 
+  if (options?.immediate) {
+    void load()
+  }
+
   return {
     page,
     pageSize,

+ 2 - 0
src/layouts/MainLayout.vue

@@ -124,6 +124,7 @@ import { useUserStore } from '@/stores/user'
 import { getTownList, getVillagesByTown } from '@/api/mock/data'
 import {
   ensureTowns,
+  ensureAllVillages,
   ensureVillages,
   listTownNames,
   listVillageNames,
@@ -173,6 +174,7 @@ async function loadTownOptions() {
   }
   try {
     await ensureTowns()
+    await ensureAllVillages()
     remoteTowns.value = listTownNames()
   } catch {
     remoteTowns.value = []

+ 14 - 1
src/types/biz.ts

@@ -196,6 +196,7 @@ export interface ProcessLogItem {
   fileUrls?: string
   remarks?: string
   bizType?: string
+  personnelId?: string
 }
 
 /** 批次流程总览 */
@@ -474,13 +475,22 @@ export interface AccountItem {
 }
 
 export interface OpLog {
-  id: number
+  id: string | number
+  rawId?: string
   time: string
   user: string
   role: string
   module: string
   action: string
   result: string
+  bizType?: string
+  opinion?: string
+  batchNo?: string
+  personnelId?: string
+  remarks?: string
+  fileUrls?: string
+  operatorOrg?: string
+  stage?: string
 }
 
 export interface SubsidyStandard {
@@ -632,4 +642,7 @@ export interface DashboardData {
 export interface PageResult<T> {
   list: T[]
   total: number
+  pageNum?: number
+  pageSize?: number
+  summary?: Record<string, unknown>
 }

+ 16 - 0
src/utils/batchAmounts.ts

@@ -57,3 +57,19 @@ export function sumBatchMemberAmounts(
   }
   return s
 }
+
+/** 发放明细分页接口回填的全量筛选合计 */
+export function paymentSummaryFromApi(
+  raw?: Record<string, unknown> | null,
+): BatchAmountSummary | null {
+  if (!raw) return null
+  const count = Number(raw.peopleCount ?? raw.count ?? 0)
+  const monthly = Number(raw.monthlyAmount ?? 0)
+  const oneTime = Number(raw.oneTimeAmount ?? 0)
+  const supplement = Number(raw.supplementAmount ?? 0)
+  const funeral = Number(raw.funeralAmount ?? 0)
+  const deduct = Number(raw.deductAmount ?? 0)
+  const payable = Number(raw.totalAmount ?? raw.payableAmount ?? 0)
+  if (!count && !monthly && !supplement && !funeral && !deduct && !payable) return null
+  return { count, monthly, oneTime, supplement, funeral, deduct, payable }
+}

+ 6 - 1
src/utils/bizFiles.ts

@@ -56,7 +56,12 @@ export function parseBizFileLinks(raw?: string | null): BizFileLink[] {
       } catch {
         /* keep */
       }
-      return { href, name, isImage: IMAGE_EXT.test(name) || IMAGE_EXT.test(url) }
+      const isImage =
+        IMAGE_EXT.test(name) ||
+        IMAGE_EXT.test(url) ||
+        /\/static\/(photo|image|img)\//i.test(url) ||
+        /\/photo\//i.test(href)
+      return { href, name, isImage }
     })
     .filter((f) => !!f.href)
 }

+ 36 - 2
src/utils/buildDashboard.ts

@@ -383,7 +383,7 @@ function buildTownReviewData(
     }
   })
 
-  const pendingPeople: TownPendingPersonItem[] = pendingPeopleRaw.slice(0, 20).map((p) => ({
+  const pendingPeople: TownPendingPersonItem[] = pendingPeopleRaw.map((p) => ({
     id: p.id,
     name: p.name,
     idCard: p.idCard,
@@ -548,6 +548,40 @@ function buildDistrictOverview(
   }
 }
 
+/** 待办只给能办理的角色;区农委/人社只监测不列办理项 */
+export function todoTypeAllowedForRole(roleKey: string | undefined, type: string): boolean {
+  const k = String(roleKey || '').toLowerCase()
+  if (!type) return false
+  if (!k || k === 'admin') return true
+  if (k === 'agri' || k === 'hr') return false
+  if (k === 'village') return type === '待提交资格' || type === '告警'
+  if (k === 'town') return type === '待资格审批'
+  if (k === 'social') {
+    return (
+      type === '待居保匹配' ||
+      type === '待人工确认' ||
+      type === '待提交月度清单' ||
+      type === '待出盘' ||
+      type === '待回盘' ||
+      type === '告警'
+    )
+  }
+  if (k === 'leader') return type === '待领导审批' || type === '待特殊业务审批'
+  return false
+}
+
+export function filterTodosByRole<T extends { type: string; path?: string }>(
+  list: T[],
+  roleKey: string | undefined,
+  allowedPaths?: string[],
+): T[] {
+  return (list || []).filter((t) => {
+    if (!todoTypeAllowedForRole(roleKey, t.type)) return false
+    if (allowedPaths && t.path && !allowedPaths.includes(t.path)) return false
+    return true
+  })
+}
+
 /** 待办:按角色过滤贴近各自节点 */
 function buildTodos(
   role: RoleItem,
@@ -604,7 +638,7 @@ function buildTodos(
 
 /** 近期告警(无数据返回空,不造假) */
 function buildRecentAlerts(scopeAlerts: AlertItem[]): AlertItem[] {
-  return scopeAlerts.slice(0, 8)
+  return scopeAlerts
 }
 
 /** 根据角色与发放周期构建工作台数据 */

+ 1 - 1
src/utils/personnelStatusLog.ts

@@ -19,7 +19,7 @@ export function isPersonnelInsuranceLog(log: ProcessLogItem) {
 }
 
 export function isPersonnelQualifyLog(log: ProcessLogItem) {
-  return /RETIRE_QUALIFY|QUALIFY_SUBMIT|QUALIFY_PASS|QUALIFY_REJECT|退养资格|资格审批|资格提交|资格→/i.test(
+  return /RETIRE_QUALIFY|QUALIFY_SUBMIT|QUALIFY_PASS|QUALIFY_REJECT|QUALIFY_REJECT_CLOSED|退养资格|资格审批|资格提交|资格→/i.test(
     logText(log),
   )
 }

+ 17 - 3
src/utils/request.ts

@@ -113,10 +113,20 @@ function releaseSubmitLock(config?: SubmitAwareConfig) {
   config.__lockExpireTimer = undefined
 }
 
+/** 4xx 业务提示用警告;5xx/网络异常用错误红 */
+function toastFail(message: string, code?: number) {
+  if (!message) return
+  if (code != null && code >= 400 && code < 500) {
+    ElMessage.warning(message)
+    return
+  }
+  ElMessage.error(message)
+}
+
 function axiosErrorMessage(error: {
   code?: string
   message?: string
-  response?: { data?: { message?: string; msg?: string } }
+  response?: { status?: number; data?: { code?: number; message?: string; msg?: string } }
 }): string {
   if (error?.code === DUPLICATE_SUBMIT || error?.message === DUPLICATE_SUBMIT) {
     return ''
@@ -193,11 +203,14 @@ service.interceptors.response.use(
       return response
     }
     if (res.code === 0 || res.code === 200) {
+      if (shouldLockSubmit(response.config as RequestConfig)) {
+        clearGetCache()
+      }
       return response
     }
     const silent = Boolean((response.config as RequestConfig | undefined)?.silent)
     if (!silent) {
-      ElMessage.error(res.message || res.msg || '请求失败')
+      toastFail(res.message || res.msg || '请求失败', Number(res.code))
     }
     return Promise.reject(new Error(res.message || res.msg || '请求失败'))
   },
@@ -209,7 +222,8 @@ service.interceptors.response.use(
     const silent = Boolean((error.config as RequestConfig | undefined)?.silent)
     if (!silent) {
       const msg = axiosErrorMessage(error)
-      if (msg) ElMessage.error(msg)
+      const code = Number(error?.response?.data?.code || error?.response?.status || 0)
+      if (msg) toastFail(msg, code)
     }
     return Promise.reject(error)
   },

+ 13 - 0
src/utils/statusLabels.ts

@@ -129,6 +129,7 @@ const PROCESS_ACTION: Record<string, string> = {
   QUALIFY_SUBMIT: '提交退养资格',
   QUALIFY_PASS: '退养资格审批通过',
   QUALIFY_REJECT: '退养资格审批驳回',
+  QUALIFY_REJECT_CLOSED: '资格驳回告警已关闭,退回待提交资格',
   RETIRE_QUALIFY: '退养资格审批',
   RETIRE_QUALIFY_SNAPSHOT: '退养资格',
   STATUS_NEW: '新增',
@@ -155,8 +156,10 @@ const PROCESS_ACTION: Record<string, string> = {
   RETURN_ARREARS: '回盘失败待补发',
   SYS_USER: '系统用户',
   SYS_CONFIG: '系统配置',
+  LOGIN_LOG: '登录日志',
   LOGIN_AUDIT: '登录审计',
   LOGOUT_AUDIT: '登出审计',
+  OP_LOG: '操作日志',
   STANDARD_CHANGE: '标准变更',
   STANDARD_ADJUST: '调标差额补发',
   REFUND: '多发退款',
@@ -247,6 +250,16 @@ export function personnelStatusLabel(code?: string | null) {
   return labelOf('personnelStatus', code)
 }
 
+/** 退养资格类型展示:空/UNSET → 待认定 */
+export function retireQualifyLabel(code?: string | null) {
+  const s = String(code || '').trim()
+  if (!s || s === '—' || s === 'UNSET' || s === 'NONE' || s === 'CLEARED') return '待认定'
+  if (s === 'WITH_LAND') return '有土地退养'
+  if (s === 'WITHOUT_LAND') return '无土地有资格'
+  if (s === 'INELIGIBLE') return '无资格'
+  return s
+}
+
 const REFUND_PROGRESS: Record<string, string> = {
   REGISTERED: '已登记',
   VERIFYING: '核实中',

+ 19 - 18
src/views/admin/AdminBatchesView.vue

@@ -117,7 +117,7 @@ import TablePagination from '@/components/common/TablePagination.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import { generateDistrictMonthBatch, listRegionsAdmin, pageAdminBatches, purgeBatchAdmin } from '@/api/biz'
 import StatusTag from '@/components/common/StatusTag.vue'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { runSubmit } from '@/composables/useSubmitLock'
 import { batchStatusLabel, flowStageLabelOf } from '@/utils/statusLabels'
 import type { RegionRow } from '@/api/sjnmtybt/regionStore'
@@ -150,15 +150,29 @@ const q = reactive({
   townId: '',
   batchLevel: '',
 })
-const list = ref<AdminBatch[]>([])
 const towns = ref<RegionRow[]>([])
-const loading = ref(false)
 const generating = ref(false)
 const drawer = ref(false)
 const drawerId = ref('')
 const drawerTab = ref<'detail' | 'flow'>('detail')
 
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: search,
+} = useServerPagination<AdminBatch>(async (pageNum, pageSize) => {
+  const res = await pageAdminBatches({
+    payMonth: q.payMonth || undefined,
+    townId: q.townId || undefined,
+    batchLevel: q.batchLevel || undefined,
+    pageNum,
+    pageSize,
+  })
+  return { list: (res.list || []) as AdminBatch[], total: Number(res.total || 0) }
+})
 
 function levelLabel(level?: string) {
   if (level === 'DISTRICT') return '全区'
@@ -170,19 +184,6 @@ function formatAmt(n: number) {
   return Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
 }
 
-async function search() {
-  loading.value = true
-  try {
-    list.value = (await pageAdminBatches({
-      payMonth: q.payMonth || undefined,
-      townId: q.townId || undefined,
-      batchLevel: q.batchLevel || undefined,
-    })) as AdminBatch[]
-    resetPage()
-  } finally {
-    loading.value = false
-  }
-}
 
 function reset() {
   q.payMonth = ''
@@ -196,7 +197,7 @@ async function onGenerateDistrict(simulateFirstDay = false) {
     await ElMessageBox.confirm(
       simulateFirstDay
         ? '联调模拟每月 1 号:将把「新增」且已有资格人员的下次发放起始月改到本月,再按正式规则生成全区批并居保。正式 1 号规则不变。'
-        : '确认生成或重跑本月全区批次?将按规则纳入人员、刷新各镇统计并触发居保匹配。已有明细不会重复加倍补发/扣款。',
+        : '确认生成或重跑本月全区批次?将按规则纳入人员、刷新各镇统计并触发居保匹配。已有明细不会重复加倍补发/扣款。人员较多时可能需要数分钟,请勿关闭页面。',
       simulateFirstDay ? '联调:按1号纳入新增' : '生成全区月批',
       { type: 'warning', confirmButtonText: simulateFirstDay ? '确认纳入并生成' : '确认生成' },
     )

+ 115 - 26
src/views/admin/PersonnelAdminView.vue

@@ -14,9 +14,12 @@
         >
           批量删除{{ selected.length ? `(${selected.length})` : '' }}
         </el-button>
+        <el-button type="danger" size="small" :loading="clearing" @click="onClearAll">
+          清空全部
+        </el-button>
       </template>
       <p class="page-tip">
-        全区人员底数运维。可改基础信息、变更状态、删除(归档)。「初始化状态」可一键改全员(或当前筛选街镇/村)的业务状态、居保匹配与退养资格,不改姓名银行卡。
+        全区人员底数运维。可改基础信息、变更状态、删除(归档)。「初始化状态」可一键改全员(或当前筛选街镇/村)的业务状态、居保匹配与退养资格,不改姓名银行卡。「清空全部」会销毁栏目中全部人员底数。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -33,6 +36,21 @@
           <el-form-item label="姓名">
             <el-input v-model="keyword" clearable placeholder="模糊" style="width: 120px" />
           </el-form-item>
+          <el-form-item label="业务状态">
+            <el-select v-model="filterBizStatus" clearable placeholder="全部" style="width: 130px">
+              <el-option v-for="s in bizStatusOptions" :key="s.value" :label="s.label" :value="s.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="居保匹配">
+            <el-select v-model="filterInsurance" clearable placeholder="全部" style="width: 120px">
+              <el-option v-for="s in insuranceOptions" :key="s.value" :label="s.label" :value="s.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="退养资格">
+            <el-select v-model="filterQualify" clearable placeholder="全部" style="width: 130px">
+              <el-option v-for="s in qualifyOptions" :key="s.value" :label="s.label" :value="s.value" />
+            </el-select>
+          </el-form-item>
           <el-form-item>
             <el-button type="primary" @click="search">查询</el-button>
             <el-button @click="reset">重置</el-button>
@@ -73,6 +91,11 @@
             <StatusTag :status="row.insuranceStatus || '未匹配'" />
           </template>
         </el-table-column>
+        <el-table-column label="退养资格" width="120" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="retireQualifyLabel(row.retireQualifyType)" />
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="300" align="center" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
@@ -221,16 +244,17 @@ import TablePagination from '@/components/common/TablePagination.vue'
 import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
 import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
+  clearAllPersonnelAdmin,
   deletePersonnelAdmin,
   deletePersonnelBatchAdmin,
-  fetchPeople,
+  fetchPeoplePage,
   fetchPersonDetail,
   initPersonnelStatusAdmin,
   savePerson,
   updatePersonnelStatusAdmin,
 } from '@/api/biz'
 import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { ensureVillages, listVillageNames, resolveScopeIds } from '@/api/sjnmtybt/regionStore'
@@ -238,7 +262,7 @@ import { getAppConfig } from '@/utils/appConfig'
 import { getVillagesByTown } from '@/api/mock/data'
 import { maskIdCard } from '@/utils/mask'
 import { explainIdCard, isValidPhoneOptional } from '@/utils/validators'
-import { personnelStatusLabel } from '@/utils/statusLabels'
+import { personnelStatusLabel, retireQualifyLabel } from '@/utils/statusLabels'
 import type { Person } from '@/types/biz'
 
 const {
@@ -251,10 +275,38 @@ const {
 } = useTownVillageOptions()
 
 const keyword = ref('')
-const list = ref<Person[]>([])
-const loading = ref(false)
+const filterBizStatus = ref('')
+const filterInsurance = ref('')
+const filterQualify = ref('')
+const bizStatusOptions = [
+  { label: '待提交资格', value: 'DRAFT' },
+  { label: '待资格审批', value: 'PENDING_TOWN_APPROVE' },
+  { label: '新增', value: 'NEW' },
+  { label: '正常发放', value: 'NORMAL' },
+  { label: '已退出', value: 'EXITED' },
+  { label: '待居保匹配', value: 'PENDING_INSURANCE_MATCH' },
+  { label: '暂停', value: 'PAUSED' },
+  { label: '停止', value: 'STOPPED' },
+  { label: '死亡丧葬', value: 'FUNERAL' },
+  { label: '异常待处理', value: 'ABNORMAL' },
+  { label: '已归档', value: 'ARCHIVED' },
+]
+const insuranceOptions = [
+  { label: '匹配通过', value: 'SUCCESS' },
+  { label: '人工通过', value: 'MANUAL_PASS' },
+  { label: '未匹配', value: 'PENDING' },
+  { label: '匹配失败', value: 'FAIL' },
+  { label: '丧失资格', value: 'LOSE_ELIGIBILITY' },
+]
+const qualifyOptions = [
+  { label: '待认定', value: 'UNSET' },
+  { label: '有土地退养', value: 'WITH_LAND' },
+  { label: '无土地有资格', value: 'WITHOUT_LAND' },
+  { label: '无资格', value: 'INELIGIBLE' },
+]
 const saving = ref(false)
 const batchDeleting = ref(false)
+const clearing = ref(false)
 const initing = ref(false)
 const initDlg = ref(false)
 const tableRef = ref<TableInstance>()
@@ -286,7 +338,28 @@ function onEditFormUpdate(v: Person) {
   editForm.value = v
 }
 
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: search,
+} = useServerPagination<Person>(async (pageNum, pageSize) => {
+  const kw = keyword.value.trim()
+  const idLike = /^\d{6,}/.test(kw) || /[0-9Xx]{15,}/.test(kw)
+  return fetchPeoplePage({
+    town: filterTown.value || undefined,
+    village: filterVillage.value || undefined,
+    name: kw && !idLike ? kw : undefined,
+    idCard: kw && idLike ? kw : undefined,
+    bizStatus: filterBizStatus.value || undefined,
+    insuranceMatchResult: filterInsurance.value || undefined,
+    retireQualifyType: filterQualify.value || undefined,
+    pageNum,
+    pageSize,
+  })
+})
 
 async function loadEditVillages(town: string) {
   if (!town) {
@@ -317,29 +390,13 @@ async function openDetail(row: Person) {
   }
 }
 
-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()
-    tableRef.value?.clearSelection()
-    selected.value = []
-  } finally {
-    loading.value = false
-  }
-}
 
 function reset() {
   resetTownVillage()
   keyword.value = ''
+  filterBizStatus.value = ''
+  filterInsurance.value = ''
+  filterQualify.value = ''
   search()
 }
 
@@ -583,6 +640,38 @@ async function onBatchDelete() {
   }
 }
 
+async function onClearAll() {
+  try {
+    await ElMessageBox.confirm(
+      '将销毁栏目中全部人员底数,并从未闭环批次明细中摘离。此操作不可恢复,确认继续?',
+      '清空全部人员底数',
+      { type: 'warning', confirmButtonText: '确认清空', distinguishCancelAndClose: true },
+    )
+  } catch {
+    return
+  }
+  if (guardBusy(clearing)) return
+  clearing.value = true
+  try {
+    const res = (await clearAllPersonnelAdmin()) as {
+      successCount?: number
+      failCount?: number
+      message?: string
+    }
+    const fail = Number(res?.failCount ?? 0)
+    if (fail > 0) {
+      ElMessage.warning(res?.message || `清空完成,失败 ${fail} 人`)
+    } else {
+      ElMessage.success(res?.message || '人员底数已清空')
+    }
+    tableRef.value?.clearSelection()
+    selected.value = []
+    await search()
+  } finally {
+    clearing.value = false
+  }
+}
+
 onMounted(search)
 </script>
 

+ 55 - 29
src/views/alerts/AlertsView.vue

@@ -182,6 +182,9 @@
           <el-descriptions-item label="处理结果" :span="2">
             {{ detailAlert.result || '—' }}
           </el-descriptions-item>
+          <el-descriptions-item label="证明材料" :span="2">
+            <FlowAttachments :file-urls="detailFileUrls" show-empty />
+          </el-descriptions-item>
         </el-descriptions>
         <div class="alerts-view__lifecycle-title">处理流程</div>
         <AlertLifecycleTimeline :lifecycle="detailLifecycle" :loading="detailLoading" />
@@ -209,24 +212,24 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import AlertLifecycleTimeline from '@/components/biz/AlertLifecycleTimeline.vue'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
   fetchAlertDetail,
-  fetchAlerts,
+  fetchAlertsPage,
   fetchPeople,
   fetchPersonDetail,
   handleAlert,
   savePerson,
   uploadBizFile,
 } from '@/api/biz'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import {
   useDynamicRegionOptions,
   useTownVillageOptions,
 } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
-import { filterByRoleScope } from '@/utils/buildDashboard'
 import {
   DISK_RETURN_FAIL_REASONS,
   DISK_RETURN_FAIL_TIP,
@@ -244,26 +247,30 @@ const {
   villageOptions,
   onTownChange,
   resetTownVillage,
-  matchTownVillage,
 } = useTownVillageOptions()
 
-const list = ref<AlertItem[]>([])
-const loading = ref(false)
-
-const filteredList = computed(() => {
-  if (!isAdmin.value) return list.value
-  return list.value.filter((row) => matchTownVillage(row))
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: loadData,
+} = useServerPagination<AlertItem>(async (pageNum, pageSize) => {
+  const role = userStore.roleInfo
+  const town = isAdmin.value ? filterTown.value || undefined : role?.town
+  const village = isAdmin.value ? filterVillage.value || undefined : role?.village
+  return fetchAlertsPage({ town, village, pageNum, pageSize })
 })
-
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(filteredList)
+const list = pagedList
 
 function applyFilter() {
-  resetPage()
+  void loadData()
 }
 
 function resetFilter() {
   resetTownVillage()
-  resetPage()
+  void loadData()
 }
 
 const dialogVisible = ref(false)
@@ -277,6 +284,23 @@ const detailVisible = ref(false)
 const detailLoading = ref(false)
 const detailAlert = ref<AlertItem | null>(null)
 const detailLifecycle = ref<AlertLifecycleItem[]>([])
+const detailFileUrls = computed(() => {
+  const fromAlert = detailAlert.value?.fileUrls
+  const fromLife = (detailLifecycle.value || []).map((i) => i.fileUrls).filter(Boolean)
+  const parts = [fromAlert, ...fromLife].filter(Boolean) as string[]
+  const seen = new Set<string>()
+  const uniq: string[] = []
+  for (const part of parts) {
+    for (const u of part.split(/[,;\n]/)) {
+      const t = u.trim()
+      if (t && !seen.has(t)) {
+        seen.add(t)
+        uniq.push(t)
+      }
+    }
+  }
+  return uniq.join(',')
+})
 
 const diskFailReasons = DISK_RETURN_FAIL_REASONS
 const diskFailTip = DISK_RETURN_FAIL_TIP
@@ -515,6 +539,8 @@ async function onSubmitHandle() {
       const r = await uploadBizFile(file)
       if (r?.url) uploaded.push(r.url)
     }
+    const closedId = String(currentAlert.value.id)
+    const closedResult = handleRemarks.value.trim()
     await handleAlert({
       id: currentAlert.value.id,
       personName: f.name.trim(),
@@ -532,9 +558,19 @@ async function onSubmitHandle() {
       remarks: handleRemarks.value.trim(),
       fileUrls: uploaded.length ? uploaded.join(',') : undefined,
     })
-    ElMessage.success('告警已处理并关闭(人员与继承人信息已保存,村/镇状态已同步)')
+    const qualifyReject = String(currentAlert.value.type || '').includes('退养资格')
+    ElMessage.success(
+      qualifyReject
+        ? '告警已关闭,人员已退回待提交资格,可重新提交审批'
+        : '告警已处理并关闭(人员与继承人信息已保存,村/镇状态已同步)',
+    )
     dialogVisible.value = false
     await loadData()
+    const row = list.value.find((a) => String(a.id) === closedId)
+    if (row && row.status !== '已关闭') {
+      row.status = '已关闭'
+      row.result = closedResult
+    }
   } finally {
     saving.value = false
   }
@@ -549,20 +585,10 @@ async function openFromRouteQuery() {
   if (hit) await openDetailDialog(hit)
 }
 
-async function loadData() {
-  loading.value = true
-  try {
-    const all = await fetchAlerts()
-    const role = userStore.roleInfo
-    list.value = role ? filterByRoleScope(all, role) : all
-    resetPage()
-    await openFromRouteQuery()
-  } finally {
-    loading.value = false
-  }
-}
-
-onMounted(loadData)
+onMounted(async () => {
+  await loadData()
+  await openFromRouteQuery()
+})
 
 watch(
   () => route.query.id,

+ 96 - 71
src/views/approvals/ApprovalsView.vue

@@ -665,15 +665,16 @@ import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
 import {
   decideApproval,
+  decideApprovals,
   fetchApprovals,
-  fetchBatchMemberPeople,
+  fetchBatchMemberPeoplePage,
   fetchPersonDetail,
   fetchSpecialBizPerson,
   reviewPersonQualify,
   uploadBizFile,
 } from '@/api/biz'
 import { parseApprovalId } from '@/api/sjnmtybt/mappers'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
@@ -698,8 +699,6 @@ const activeTab = ref('pending')
 const isHistoryTab = computed(() => activeTab.value === 'history')
 const filterBatchNo = ref('')
 const filterStatus = ref('')
-const list = ref<Approval[]>([])
-const loading = ref(false)
 const deciding = ref(false)
 const selectedIds = ref<string[]>([])
 const detailVisible = ref(false)
@@ -841,20 +840,8 @@ function qualifyTagType(t?: string): 'success' | 'warning' | 'danger' | 'info' {
   return 'success'
 }
 
-const filteredList = computed(() => {
-  let rows = list.value
-  if (filterTown.value) rows = rows.filter((row) => row.town === filterTown.value)
-  if (filterVillage.value) rows = rows.filter((row) => row.village === filterVillage.value)
-  const batchNo = filterBatchNo.value.trim()
-  if (batchNo) rows = rows.filter((row) => row.batchNo.includes(batchNo))
-  if (filterStatus.value) rows = rows.filter((row) => row.status === filterStatus.value)
-  return rows
-})
-
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(filteredList)
-
 function applyFilter() {
-  resetPage()
+  void loadApprovals()
 }
 
 function resetFilter() {
@@ -865,7 +852,7 @@ function resetFilter() {
   } else {
     filterVillage.value = ''
   }
-  resetPage()
+  void loadApprovals()
 }
 
 async function syncScopeTown() {
@@ -887,13 +874,13 @@ 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(filteredDetailPeople)
+const detailPeoplePage = ref(1)
+const detailPeoplePageSize = ref(10)
+const detailPeopleTotal = ref(0)
+const pagedDetailPeople = filteredDetailPeople
+function resetDetailPeoplePage() {
+  detailPeoplePage.value = 1
+}
 
 /** 按角色限定审批节点 */
 const approvalNode = computed(() => {
@@ -905,6 +892,30 @@ const approvalNode = computed(() => {
 const isTownReview = computed(() => userStore.currentRole === 'town')
 const isLeaderReview = computed(() => userStore.currentRole === 'leader')
 
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: loadApprovals,
+} = useServerPagination<Approval>(async (pageNum, pageSize) => {
+  selectedIds.value = []
+  const role = userStore.roleInfo
+  const isPending = activeTab.value === 'pending'
+  return fetchApprovals({
+    tab: isPending ? 'pending' : 'history',
+    node: approvalNode.value,
+    town: isTownReview.value || isLeaderReview.value ? role?.town : filterTown.value || undefined,
+    village: filterVillage.value || undefined,
+    batchNo: filterBatchNo.value.trim() || undefined,
+    status: isHistoryTab.value ? filterStatus.value || undefined : undefined,
+    pageNum,
+    pageSize,
+  })
+})
+const list = pagedList
+
 const pageTitle = computed(() => {
   if (isTownReview.value) return '退养资格审批'
   if (isLeaderReview.value) return '审批管理(分管领导线上审批)'
@@ -1092,14 +1103,17 @@ async function openDetail(row: Approval) {
       detailPeople.value = []
       return
     }
-    detailPeople.value = await fetchBatchMemberPeople(batchId, {
+    const peoplePage = await fetchBatchMemberPeoplePage(batchId, {
       batchNo: row.batchNo,
       town: row.town,
       village: row.village,
-      // 已提交镇审:以当时发放明细为准,不再做默认剔除
       pruneDefaults: false,
+      pageNum: 1,
+      pageSize: detailPeoplePageSize.value,
     })
-    // 以发放明细实时汇总覆盖列表上的批次头金额(含补发/丧葬/扣减)
+    detailPeople.value = peoplePage.list
+    detailPeopleTotal.value = peoplePage.total
+    detailPeoplePage.value = 1
     const sum = sumBatchMemberAmounts(detailPeople.value, {
       adjustOnly: isYearlyAdjustBatchLike({
         batchNo: row.batchNo,
@@ -1109,11 +1123,10 @@ async function openDetail(row: Approval) {
     if (currentRow.value) {
       currentRow.value = {
         ...currentRow.value,
-        peopleCount: sum.count || currentRow.value.peopleCount,
-        totalAmount: sum.payable,
+        peopleCount: peoplePage.total || currentRow.value.peopleCount,
+        totalAmount: currentRow.value.totalAmount ?? sum.payable,
       }
     }
-    resetDetailPeoplePage()
   } finally {
     peopleLoading.value = false
   }
@@ -1182,12 +1195,16 @@ async function townPassBlockReason(row: Approval, people?: Person[]): Promise<st
   if (!list) {
     const batchId = row.batchId || batchIdOfApproval(row)
     if (!batchId) return null
-    list = await fetchBatchMemberPeople(batchId, {
-      batchNo: row.batchNo,
-      town: row.town,
-      village: row.village,
-      pruneDefaults: false,
-    })
+    list = (
+      await fetchBatchMemberPeoplePage(batchId, {
+        batchNo: row.batchNo,
+        town: row.town,
+        village: row.village,
+        pruneDefaults: false,
+        pageNum: 1,
+        pageSize: 10,
+      })
+    ).list
   }
   const ineligible = list.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格')
   if (ineligible.length) {
@@ -1246,15 +1263,13 @@ async function confirmPass() {
   try {
     const fileUrls = await uploadApproveFiles(passRawFiles.value)
     if (passBatchMode.value) {
-      for (const id of selectedIds.value) {
-        await decideApproval({
-          id,
-          pass: true,
-          opinion,
-          fileUrls,
-          retireQualifyType: isTownReview.value ? passQualifyType.value : undefined,
-        })
-      }
+      await decideApprovals({
+        ids: selectedIds.value,
+        pass: true,
+        opinion,
+        fileUrls,
+        retireQualifyType: isTownReview.value ? passQualifyType.value : undefined,
+      })
       ElMessage.success(isTownReview.value ? '批量资格审批通过' : '批量通过成功')
       selectedIds.value = []
       passVisible.value = false
@@ -1284,20 +1299,18 @@ async function confirmReject() {
   try {
     const fileUrls = await uploadApproveFiles(rejectRawFiles.value)
     if (rejectBatchMode.value) {
-      for (const id of selectedIds.value) {
-        await decideApproval({
-          id,
-          pass: false,
-          opinion: reason,
-          fileUrls,
-          retireQualifyType:
-            isTownReview.value
-              ? rejectQualifyMode.value === 'ineligible'
-                ? '无资格'
-                : '有土地退养'
-              : undefined,
-        })
-      }
+      await decideApprovals({
+        ids: selectedIds.value,
+        pass: false,
+        opinion: reason,
+        fileUrls,
+        retireQualifyType:
+          isTownReview.value
+            ? rejectQualifyMode.value === 'ineligible'
+              ? '无资格'
+              : '有土地退养'
+            : undefined,
+      })
       ElMessage.success('批量驳回成功')
       selectedIds.value = []
       rejectVisible.value = false
@@ -1346,20 +1359,25 @@ async function onBatchDecide(pass: boolean) {
   passVisible.value = true
 }
 
-async function loadApprovals() {
-  loading.value = true
-  selectedIds.value = []
+async function reloadDetailPeople() {
+  const row = currentRow.value
+  if (!row || isQualifyApproval(row) || isSpecialApproval(row)) return
+  const batchId = row.batchId || batchIdOfApproval(row)
+  if (!batchId) return
+  peopleLoading.value = true
   try {
-    const role = userStore.roleInfo
-    const isPending = activeTab.value === 'pending'
-    list.value = await fetchApprovals({
-      tab: isPending ? 'pending' : 'history',
-      node: approvalNode.value,
-      town: isTownReview.value || isLeaderReview.value ? role?.town : undefined,
+    const peoplePage = await fetchBatchMemberPeoplePage(batchId, {
+      batchNo: row.batchNo,
+      town: row.town,
+      village: row.village,
+      pruneDefaults: false,
+      pageNum: detailPeoplePage.value,
+      pageSize: detailPeoplePageSize.value,
     })
-    resetPage()
+    detailPeople.value = peoplePage.list
+    detailPeopleTotal.value = peoplePage.total
   } finally {
-    loading.value = false
+    peopleLoading.value = false
   }
 }
 
@@ -1368,6 +1386,13 @@ function onTabChange() {
   loadApprovals()
 }
 
+watch([detailPeoplePage, detailPeoplePageSize], () => {
+  if (!detailVisible.value) return
+  const row = currentRow.value
+  if (!row || isQualifyApproval(row) || isSpecialApproval(row)) return
+  void reloadDetailPeople()
+})
+
 onMounted(async () => {
   await syncScopeTown()
   await loadApprovals()

+ 93 - 33
src/views/base/BaseInfoView.vue

@@ -31,11 +31,12 @@
       <el-alert class="page-alert" type="info" :closable="false" show-icon>
         <template #title>
           <template v-if="isAdmin">
-            运维查看人员底数,请先选街镇与村居(<em>{{ villageScopeLabel }}</em>)。日常新增、提交资格仍按村办理;资格通过为「新增」,每月 1 号纳入全区月发。本页不展示批次。
+            运维查看人员底数,当前范围:<em>{{ villageScopeLabel }}</em>
+            。可不选查看全区,选街镇看本镇,再选村居看本村。单人录入/导入仍须先选街镇与村居。资格通过为「新增」,每月 1 号纳入全区月发。
           </template>
           <template v-else>
             当前村:<em>{{ villageScopeLabel }}</em>
-            。录入或导入后为「待提交资格」,材料齐全后点「提交资格」送镇经发;通过后为「新增」。若列表仍是「新增」且尚未送审,也可再点「提交资格」。本页不展示批次。
+            。录入或导入后为「待提交资格」,材料齐全后点「提交资格」送镇经发;通过后为「新增」,等每月 1 号纳入全区月发。本页不展示批次。
           </template>
         </template>
       </el-alert>
@@ -116,6 +117,38 @@
             />
           </el-select>
         </el-form-item>
+        <el-form-item label="居保匹配">
+          <el-select
+            v-model="rosterFilter.insuranceMatchResult"
+            clearable
+            placeholder="全部"
+            style="width: 120px"
+            class="is-editable"
+          >
+            <el-option
+              v-for="s in rosterInsuranceOptions"
+              :key="s.value"
+              :label="s.label"
+              :value="s.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="退养资格">
+          <el-select
+            v-model="rosterFilter.retireQualifyType"
+            clearable
+            placeholder="全部"
+            style="width: 130px"
+            class="is-editable"
+          >
+            <el-option
+              v-for="s in rosterQualifyOptions"
+              :key="s.value"
+              :label="s.label"
+              :value="s.value"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item label="来源">
           <el-select
             v-model="rosterFilter.source"
@@ -161,14 +194,24 @@
           <el-table-column label="开户行" min-width="100" show-overflow-tooltip>
             <template #default="{ row }">{{ row.bankName || '—' }}</template>
           </el-table-column>
-          <el-table-column label="月补标准(街镇)" width="120" align="right">
+          <el-table-column label="月补标准(街镇)" min-width="130" align="right">
             <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
           </el-table-column>
-          <el-table-column label="状态" width="100" align="center">
+          <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="120" align="center">
+            <template #default="{ row }">
+              <StatusTag :status="retireQualifyLabel(row.retireQualifyType)" />
+            </template>
+          </el-table-column>
           <el-table-column prop="source" label="来源" width="100" show-overflow-tooltip />
           <el-table-column label="操作" min-width="280" align="center" fixed="right">
             <template #default="{ row }">
@@ -549,6 +592,7 @@ import {
   importPersonnelExcel,
   savePerson,
   submitRetireQualify,
+  submitRetireQualifyBatch,
 } from '@/api/biz'
 import type {
   PersonnelImportHistoryItem,
@@ -560,6 +604,7 @@ import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
+import { retireQualifyLabel } from '@/utils/statusLabels'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import {
   explainIdCard,
@@ -601,6 +646,8 @@ const rosterFilter = reactive({
   idCard: '',
   phone: '',
   bizStatus: '',
+  insuranceMatchResult: '',
+  retireQualifyType: '',
   source: '',
 })
 const rosterStatusOptions = [
@@ -616,6 +663,19 @@ const rosterStatusOptions = [
   { label: '异常待处理', value: 'ABNORMAL' },
   { label: '已归档', value: 'ARCHIVED' },
 ]
+const rosterInsuranceOptions = [
+  { label: '匹配通过', value: 'SUCCESS' },
+  { label: '人工通过', value: 'MANUAL_PASS' },
+  { label: '未匹配', value: 'PENDING' },
+  { label: '匹配失败', value: 'FAIL' },
+  { label: '丧失资格', value: 'LOSE_ELIGIBILITY' },
+]
+const rosterQualifyOptions = [
+  { label: '待认定', value: 'UNSET' },
+  { label: '有土地退养', value: 'WITH_LAND' },
+  { label: '无土地有资格', value: 'WITHOUT_LAND' },
+  { label: '无资格', value: 'INELIGIBLE' },
+]
 const rosterSourceOptions = ['村级录入', 'Excel导入', '历史台账', '死亡名单']
 
 const personDetailVisible = ref(false)
@@ -686,6 +746,7 @@ async function openPersonAlerts(row: Person) {
 function onPersonAlertClosed() {
   personAlertTarget.value = null
   personAlerts.value = []
+  void peoplePager.load()
 }
 
 async function openAlertLifecycle(row: AlertItem) {
@@ -743,6 +804,8 @@ function resetRosterFilter() {
   rosterFilter.idCard = ''
   rosterFilter.phone = ''
   rosterFilter.bizStatus = ''
+  rosterFilter.insuranceMatchResult = ''
+  rosterFilter.retireQualifyType = ''
   rosterFilter.source = ''
   void peoplePager.resetAndLoad()
 }
@@ -857,16 +920,15 @@ const peoplePager = useServerPagination<Person>(
   async (pageNum, pageSize) => {
     const role = userStore.roleInfo
     if (isAdmin.value) {
-      if (!filterTown.value || !filterVillage.value) {
-        return { list: [], total: 0 }
-      }
       return fetchPeoplePage({
-        town: filterTown.value,
-        village: filterVillage.value,
+        town: filterTown.value || undefined,
+        village: filterVillage.value || undefined,
         name: rosterFilter.name.trim() || undefined,
         idCard: rosterFilter.idCard.trim().replace(/\s/g, '') || undefined,
         phone: rosterFilter.phone.trim() || undefined,
         bizStatus: rosterFilter.bizStatus || undefined,
+        insuranceMatchResult: rosterFilter.insuranceMatchResult || undefined,
+        retireQualifyType: rosterFilter.retireQualifyType || undefined,
         source: rosterFilter.source.trim() || undefined,
         pageNum,
         pageSize,
@@ -880,6 +942,8 @@ const peoplePager = useServerPagination<Person>(
       idCard: rosterFilter.idCard.trim().replace(/\s/g, '') || undefined,
       phone: rosterFilter.phone.trim() || undefined,
       bizStatus: rosterFilter.bizStatus || undefined,
+      insuranceMatchResult: rosterFilter.insuranceMatchResult || undefined,
+      retireQualifyType: rosterFilter.retireQualifyType || undefined,
       source: rosterFilter.source.trim() || undefined,
       pageNum,
       pageSize,
@@ -919,8 +983,8 @@ const villageScopeLabel = computed(() => {
   const role = userStore.roleInfo
   if (isAdmin.value) {
     if (filterTown.value && filterVillage.value) return `${filterTown.value}${filterVillage.value}`
-    if (filterTown.value) return `${filterTown.value}(请选择村居)`
-    return '全区(请选择街镇与村居)'
+    if (filterTown.value) return `${filterTown.value}(全镇)`
+    return '全区'
   }
   if (role?.village) return `${role.town || ''}${role.village}`
   if (role?.town && !role.allData) return `${role.town}(请使用村合作社账号)`
@@ -994,20 +1058,17 @@ function canSubmitQualify(row: Person) {
   const s = String(row.status || '')
   const biz = String(row.bizStatus || '').toUpperCase()
   if (s === '待资格审批' || biz === 'PENDING_TOWN_APPROVE') return false
-  // 待提交、驳回补材料、以及底数里仍是「新增」但未再送审的,村都可以提交
+  if (s === '新增' || biz === 'NEW') return false
+  // 待提交资格、驳回补材料后的异常待处理,村可以提交
   if (s === '待提交资格' || s === '草稿' || s === 'DRAFT' || biz === 'DRAFT') return true
-  if (s === '新增' || biz === 'NEW') return true
   if ((s === '异常待处理' || biz === 'ABNORMAL') && row.retireQualifyType !== '无资格') return true
   return false
 }
 
 async function onSubmitQualify(row: Person) {
-  const alreadyNew = row.status === '新增' || String(row.bizStatus || '').toUpperCase() === 'NEW'
   try {
     await ElMessageBox.confirm(
-      alreadyNew
-        ? `「${row.name}」当前为新增。提交后将重新进入镇经发资格审批,通过前不再按新增纳入月发。`
-        : `确认将「${row.name}」提交镇经发中心退养资格审批?`,
+      `确认将「${row.name}」提交镇经发中心退养资格审批?`,
       '提交资格',
       { type: 'warning', confirmButtonText: '确认提交' },
     )
@@ -1019,7 +1080,7 @@ async function onSubmitQualify(row: Person) {
   qualifySubmittingId.value = id
   try {
     const updated = await submitRetireQualify(id)
-    ElMessage.success('已提交退养资格审批')
+    ElMessage.success('已提交,业务状态变为「待资格审批」,请等待镇经发中心办理')
     selectedPeople.value = []
     await loadPeople()
     patchPersonInList(id, updated)
@@ -1036,7 +1097,7 @@ async function onBatchSubmitQualify() {
   }
   try {
     await ElMessageBox.confirm(
-      `确认将已选 ${rows.length} 人提交镇经发中心退养资格审批?其中「新增」人员会重新进入待审。`,
+      `确认将已选 ${rows.length} 人提交镇经发中心退养资格审批?`,
       '批量提交资格',
       { type: 'warning', confirmButtonText: '确认提交' },
     )
@@ -1045,24 +1106,23 @@ async function onBatchSubmitQualify() {
   }
   if (guardBusy(qualifyBatching)) return
   qualifyBatching.value = true
-  let ok = 0
-  let fail = 0
-  const patched: Person[] = []
   try {
-    for (const row of rows) {
-      try {
-        const updated = await submitRetireQualify(String(row.id))
-        if (updated) patched.push(updated)
-        ok += 1
-      } catch {
-        fail += 1
-      }
+    const res = await submitRetireQualifyBatch({
+      personnelIds: rows.map((r) => String(r.id)),
+    })
+    const ok = Number(res?.successCount || 0)
+    const pending = Number(res?.pendingCount || 0)
+    const fail = Number(res?.failCount || 0)
+    if (fail === 0 && pending === 0) ElMessage.success(res?.message || `已提交 ${ok} 人`)
+    else if (fail === 0) {
+      ElMessage.warning(
+        res?.message || `已提交 ${ok} 人;${pending} 人已在资格审批中,请等待镇经发中心办理`,
+      )
+    } else {
+      ElMessage.warning(res?.message || `提交完成:成功 ${ok} 人,失败 ${fail} 人`)
     }
-    if (fail === 0) ElMessage.success(`已提交 ${ok} 人`)
-    else ElMessage.warning(`提交完成:成功 ${ok} 人,失败 ${fail} 人`)
     selectedPeople.value = []
     await loadPeople()
-    for (const u of patched) patchPersonInList(String(u.id), u)
   } finally {
     qualifyBatching.value = false
   }

+ 57 - 7
src/views/dashboard/DashboardView.vue

@@ -15,7 +15,7 @@
       <div class="alert-ticker__label">
         <el-icon><Bell /></el-icon>
         <span>近期告警</span>
-        <em v-if="alertTickerItems.length">{{ alertTickerItems.length }}</em>
+        <em v-if="alertTickerItems.length">未关闭 {{ alertTickerItems.length }}</em>
       </div>
       <div class="alert-ticker__track-wrap" @mouseenter="tickerPaused = true" @mouseleave="tickerPaused = false">
         <div
@@ -115,7 +115,7 @@
         </span>
       </template>
       <el-table
-        :data="data?.todos ?? []"
+        :data="pagedTodos"
         border
         stripe
         size="small"
@@ -138,12 +138,26 @@
         </el-table-column>
         <el-table-column label="操作" width="100" align="center">
           <template #default="{ row }">
-            <el-button link type="primary" size="small" @click.stop="onTodoClick(row)">
+            <el-button
+              v-if="canHandleTodo(row)"
+              link
+              type="primary"
+              size="small"
+              @click.stop="onTodoClick(row)"
+            >
               办理
             </el-button>
+            <span v-else class="text-muted">—</span>
           </template>
         </el-table-column>
       </el-table>
+      <TablePagination
+        v-if="todoTotal > todoPageSize"
+        v-model:page="todoPage"
+        v-model:page-size="todoPageSize"
+        :total="todoTotal"
+        :page-sizes="TODO_PAGE_SIZES"
+      />
     </PagePanel>
 
     <!-- 村合作社:左图右提示,占满可视区域避免大块留白 -->
@@ -455,7 +469,10 @@
 
       <div class="dashboard-view__tables">
         <PagePanel title="近期告警">
-          <el-table :data="data?.recentAlerts ?? []" border stripe size="small">
+          <template #extra>
+            <span class="dashboard-todos-panel__count">共 {{ alertTotal }} 条</span>
+          </template>
+          <el-table :data="pagedAlerts" border stripe size="small">
             <el-table-column prop="level" label="等级" width="72" align="center">
               <template #default="{ row }">
                 <StatusTag :status="row.level" mode="level" />
@@ -465,6 +482,13 @@
             <el-table-column prop="type" label="类型" width="110" show-overflow-tooltip />
             <el-table-column prop="content" label="内容" min-width="180" show-overflow-tooltip />
           </el-table>
+          <TablePagination
+            v-if="alertTotal > alertPageSize"
+            v-model:page="alertPage"
+            v-model:page-size="alertPageSize"
+            :total="alertTotal"
+            :page-sizes="TODO_PAGE_SIZES"
+          />
         </PagePanel>
       </div>
     </template>
@@ -478,7 +502,9 @@ import { Bell } from '@element-plus/icons-vue'
 import type { EChartsCoreOption } from 'echarts/core'
 import StatCards from '@/components/common/StatCards.vue'
 import PagePanel from '@/components/common/PagePanel.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
 import ChartBox from '@/components/common/ChartBox.vue'
+import { useClientPagination } from '@/composables/useClientPagination'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { fetchDashboard, markWorkbenchTodoRead } from '@/api/biz'
 import { useUserStore } from '@/stores/user'
@@ -1154,9 +1180,30 @@ const unreadTodoCount = computed(
   () => (data.value?.todos ?? []).filter((t) => !t.read).length,
 )
 
+const TODO_PAGE_SIZES = [5, 10, 20]
+const {
+  page: todoPage,
+  pageSize: todoPageSize,
+  total: todoTotal,
+  pagedList: pagedTodos,
+} = useClientPagination<TodoItem>(() => data.value?.todos ?? [], { pageSize: 5 })
+
+const {
+  page: alertPage,
+  pageSize: alertPageSize,
+  total: alertTotal,
+  pagedList: pagedAlerts,
+} = useClientPagination<AlertItem>(() => data.value?.recentAlerts ?? [], { pageSize: 5 })
+
+function canHandleTodo(row: TodoItem) {
+  const path = row.path || ''
+  if (!path) return false
+  return userStore.canAccess(path)
+}
+
 /** 待办点击:标记已读并跳转业务页 */
 async function onTodoClick(row: TodoItem) {
-  if (!row) return
+  if (!row || !canHandleTodo(row)) return
   const key = row.id || ''
   if (key && !row.read) {
     row.read = true
@@ -1165,8 +1212,7 @@ async function onTodoClick(row: TodoItem) {
       data.value.batchInfo.todoCount = unreadTodoCount.value
     }
   }
-  const path = row.path || '/approvals'
-  void router.push(path)
+  void router.push(row.path || '/approvals')
 }
 
 /** 切换发放周期(写入 store,各角色工作台/业务页共用) */
@@ -1241,6 +1287,10 @@ watch(
   :deep(.is-todo-read) {
     color: #9a8b7a;
   }
+
+  .text-muted {
+    color: #b5a898;
+  }
 }
 
 .alert-ticker {

+ 17 - 23
src/views/disk/DiskReturnView.vue

@@ -215,10 +215,10 @@ import {
   archiveToNextMonth,
   confirmDiskFailPaid,
   fetchDiskFailPeople,
-  fetchDisks,
+  fetchDisksPage,
   uploadReturnDiskFile,
 } from '@/api/biz'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
@@ -227,10 +227,8 @@ import type { DiskFailPerson, DiskRecord } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
-const { filterTown, townOptions, resetTownVillage, matchTownVillage } = useTownVillageOptions()
+const { filterTown, townOptions, resetTownVillage } = useTownVillageOptions()
 
-const list = ref<DiskRecord[]>([])
-const loading = ref(false)
 const archivingId = ref('')
 
 const uploadDialogVisible = ref(false)
@@ -249,25 +247,31 @@ const currentTown = computed(() =>
   isAdmin.value ? '' : userStore.roleInfo?.town || '',
 )
 
-const filteredList = computed(() => {
-  if (!isAdmin.value) return list.value
-  return list.value.filter((row) => matchTownVillage(row))
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: loadData,
+} = useServerPagination<DiskRecord>(async (pageNum, pageSize) => {
+  const town = isAdmin.value ? filterTown.value || undefined : currentTown.value || undefined
+  return fetchDisksPage({ town, pageNum, pageSize })
 })
+const list = pagedList
 
 /** 待回盘且待确认的批次,供上传弹窗选择(管理员按筛选街镇限定) */
 const pendingDiskOptions = computed(() =>
-  filteredList.value.filter((d) => d.status === '待回盘' && d.returnStatus === '待确认'),
+  list.value.filter((d) => d.status === '待回盘' && d.returnStatus === '待确认'),
 )
 
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(filteredList)
-
 function applyFilter() {
-  resetPage()
+  void loadData()
 }
 
 function resetFilter() {
   resetTownVillage()
-  resetPage()
+  void loadData()
 }
 
 function openUploadDialog() {
@@ -388,16 +392,6 @@ async function onConfirmPaid(row: DiskFailPerson) {
   }
 }
 
-async function loadData() {
-  loading.value = true
-  try {
-    list.value = await fetchDisks(currentTown.value || undefined)
-    resetPage()
-  } finally {
-    loading.value = false
-  }
-}
-
 onMounted(loadData)
 
 watch(

+ 51 - 62
src/views/insurance/InsuranceMatchView.vue

@@ -104,7 +104,7 @@
               type="primary"
               @click="openVillageResult(row as VillageBatch)"
             >
-              {{ row.village }}
+              {{ isDistrictBatch(row as VillageBatch) && scopedTownName() ? '本镇' : row.village }}
             </el-button>
             <span v-else>{{ row.village }}</span>
           </template>
@@ -348,17 +348,16 @@ import TablePagination from '@/components/common/TablePagination.vue'
 import {
   confirmInsuranceManual,
   exportInsuranceMatch,
-  fetchInsuranceBatches,
-  fetchInsuranceResults,
+  fetchInsuranceBatchesPage,
+  fetchInsuranceResultsPage,
   fetchInsuranceTaskLogs,
   triggerInsuranceMatch,
 } from '@/api/biz'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
-import { filterByRoleScope } from '@/utils/buildDashboard'
 import { maskIdCard, formatAmount } from '@/utils/mask'
 import {
   displayBizText,
@@ -377,7 +376,6 @@ const {
   villageOptions,
   onTownChange,
   resetTownVillage,
-  matchTownVillage,
   loadVillages,
 } = useTownVillageOptions()
 
@@ -394,10 +392,7 @@ const stageOptions = computed(() =>
     : ['居保状态匹配', '比对结果已生成'],
 )
 
-const batches = ref<VillageBatch[]>([])
-const results = ref<Person[]>([])
-const batchLoading = ref(false)
-const resultLoading = ref(false)
+const resultLoading = computed(() => resultPagerLoading.value)
 const matching = ref(false)
 const exporting = ref(false)
 const exportingBatchId = ref('')
@@ -466,47 +461,62 @@ function openBatchDrawer(row: VillageBatch, tab: 'detail' | 'flow' = 'detail') {
 const resultDialogTitle = computed(() => {
   const b = currentBatch.value
   if (!b) return '人员居保匹配情况'
-  return `${b.town}${b.village}(批次 ${b.batchNo})居保匹配情况`
+  const town = scopedTownName()
+  if (town) return `${town}(批次 ${b.batchNo})本镇居保匹配`
+  return `全区(批次 ${b.batchNo})居保匹配情况`
 })
 
+/** 非管理员必须用当前角色街镇;管理员用筛选街镇。全区月批本身没有街镇字段。 */
+function scopedTownName() {
+  if (isAdmin.value) return filterTown.value || ''
+  return userStore.roleInfo?.town || ''
+}
+
 function isDistrictBatch(b: VillageBatch) {
   return b.batchLevel === 'DISTRICT' || b.village === '全区' || (!b.town && !b.village)
 }
 
-const displayBatches = computed(() => {
-  let list = batches.value
-  const role = userStore.roleInfo
-  if (role && !role.allData) {
-    list = filterByRoleScope(list, role)
-  }
-  if (isAdmin.value || filterTown.value || filterVillage.value) {
-    // 全区月批没有街镇字段,按镇过滤时仍保留,结果接口再按本镇切片
-    list = list.filter((b) => isDistrictBatch(b) || matchTownVillage(b))
-  }
-  const batchNo = filterBatchNo.value.trim()
-  if (batchNo) list = list.filter((b) => b.batchNo.includes(batchNo))
-  if (filterStage.value) list = list.filter((b) => b.stage === filterStage.value)
-  return list
-})
-
 const {
   page: batchPage,
   pageSize: batchPageSize,
   total: batchTotal,
-  pagedList: pagedBatches,
-  resetPage: resetBatchPage,
-} = useClientPagination(displayBatches)
+  list: pagedBatches,
+  loading: batchLoading,
+  resetAndLoad: loadBatches,
+} = useServerPagination<VillageBatch>(async (pageNum, pageSize) => {
+  selectedBatches.value = []
+  return fetchInsuranceBatchesPage({
+    tab: isHistoryTab.value ? 'history' : 'todo',
+    batchNo: filterBatchNo.value.trim() || undefined,
+    stage: filterStage.value || undefined,
+    pageNum,
+    pageSize,
+  })
+})
+const batches = pagedBatches
 
 const {
   page: resultPage,
   pageSize: resultPageSize,
   total: resultTotal,
-  pagedList: pagedResults,
-  resetPage: resetResultPage,
-} = useClientPagination(results)
+  list: pagedResults,
+  loading: resultPagerLoading,
+  resetAndLoad: loadResultPage,
+} = useServerPagination<Person>(async (pageNum, pageSize) => {
+  const row = currentBatch.value
+  if (!row) return { list: [], total: 0 }
+  return fetchInsuranceResultsPage({
+    batchId: row.id,
+    batchNo: row.batchNo,
+    town: scopedTownName() || undefined,
+    village: filterVillage.value || undefined,
+    pageNum,
+    pageSize,
+  })
+})
 
 function applyFilter() {
-  resetBatchPage()
+  void loadBatches()
 }
 
 function resetFilter() {
@@ -517,7 +527,7 @@ function resetFilter() {
   } else {
     filterVillage.value = ''
   }
-  resetBatchPage()
+  void loadBatches()
 }
 
 async function syncScopeTown() {
@@ -602,23 +612,12 @@ async function openVillageResult(row: VillageBatch) {
 }
 
 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,
-    })
-    resetResultPage()
-  } finally {
-    resultLoading.value = false
-  }
+  currentBatch.value = row
+  await loadResultPage()
 }
 
 function onResultDialogClosed() {
   currentBatch.value = null
-  results.value = []
 }
 
 function openManualConfirm(row: Person) {
@@ -661,7 +660,10 @@ async function onExportBatch(row: VillageBatch) {
   if (guardBusy(exportingBatchId)) return
   exportingBatchId.value = row.id
   try {
-    const res = await exportInsuranceMatch({ batchId: row.id })
+    const res = await exportInsuranceMatch({
+      batchId: row.id,
+      town: scopedTownName() || undefined,
+    })
     if (!res?.ok) {
       ElMessage.warning(res?.message || '导出失败')
       return
@@ -696,19 +698,6 @@ async function onExport() {
   }
 }
 
-async function loadBatches() {
-  batchLoading.value = true
-  selectedBatches.value = []
-  try {
-    batches.value = await fetchInsuranceBatches({
-      tab: isHistoryTab.value ? 'history' : 'todo',
-    })
-    resetBatchPage()
-  } finally {
-    batchLoading.value = false
-  }
-}
-
 function onTabChange() {
   filterStage.value = ''
   tableRef.value?.clearSelection()

+ 74 - 43
src/views/monthly/MonthlyPaymentView.vue

@@ -399,8 +399,8 @@ import {
   downloadStoredFile,
   exportMonthlyFile,
   fetchBatchDetail,
-  fetchBatchMemberPeople,
-  fetchVillageMonthlyBatches,
+  fetchBatchMemberPeoplePage,
+  fetchVillageMonthlyBatchesPage,
   fetchPendingSpecialCount,
   issueVillageMonthlyDisk,
   listCalcRounds,
@@ -408,11 +408,11 @@ import {
   snapshotCalcRound,
   submitToLeader,
 } from '@/api/biz'
-import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy, runSubmitId } from '@/composables/useSubmitLock'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
-import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
+import { paymentSummaryFromApi, sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
 import type { BatchMemberPerson, VillageMonthlyBatch } from '@/types/biz'
@@ -426,17 +426,15 @@ const {
   villageOptions,
   onTownChange,
   resetTownVillage,
-  matchTownVillage,
 } = useTownVillageOptions()
 
-const list = ref<VillageMonthlyBatch[]>([])
-const loading = ref(false)
 const actingId = ref('')
 const pendingSpecialCount = ref(0)
 const detailVisible = ref(false)
 const detailLoading = ref(false)
 const currentRow = ref<VillageMonthlyBatch | null>(null)
 const detailPeople = ref<BatchMemberPerson[]>([])
+const detailPaymentSummary = ref<ReturnType<typeof paymentSummaryFromApi>>(null)
 const isAdjustDetailBatch = computed(() =>
   isYearlyAdjustBatchLike({
     batchNo: currentRow.value?.batchNo,
@@ -447,22 +445,39 @@ const isAdjustDetailBatch = computed(() =>
   }),
 )
 
-const detailAmountSummary = computed(() =>
-  sumBatchMemberAmounts(detailPeople.value, { adjustOnly: isAdjustDetailBatch.value }),
+const detailAmountSummary = computed(() => {
+  if (detailPaymentSummary.value) return detailPaymentSummary.value
+  const row = currentRow.value
+  if (row) {
+    return {
+      count: Number(row.peopleCount || 0),
+      monthly: Number(row.normalAmount || 0),
+      oneTime: 0,
+      supplement: Number(row.extraAmount || 0),
+      funeral: 0,
+      deduct: Number(row.deductAmount || 0),
+      payable: Number(row.totalAmount || 0),
+    }
+  }
+  return sumBatchMemberAmounts(pagedDetailPeople.value, { adjustOnly: isAdjustDetailBatch.value })
+})
+
+const detailPageAmountSummary = computed(() =>
+  sumBatchMemberAmounts(pagedDetailPeople.value, { adjustOnly: isAdjustDetailBatch.value }),
 )
 
 function detailTableSummary(param: {
   columns: Array<{ label?: string; property?: string }>
 }) {
-  const sum = detailAmountSummary.value
+  const sum = detailPageAmountSummary.value
   return param.columns.map((col, index) => {
-    if (index === 0) return '合计'
+    if (index === 0) return '本页合计'
     const label = String(col.label || col.property || '')
     if (label.includes('标准')) return formatAmount(sum.monthly)
     if (label.includes('补发')) return formatAmount(sum.supplement)
     if (label.includes('丧葬')) return formatAmount(sum.funeral)
     if (label.includes('扣减')) return formatAmount(sum.deduct)
-    if (label.includes('总计')) return formatAmount(sum.payable)
+    if (label.includes('总计') || label.includes('应发')) return formatAmount(sum.payable)
     return ''
   })
 }
@@ -496,27 +511,52 @@ function openBatchDrawer(row: VillageMonthlyBatch, tab: 'detail' | 'flow' = 'det
   batchDrawerVisible.value = true
 }
 
-const filteredList = computed(() => {
-  if (!isAdmin.value) return list.value
-  return list.value.filter((row) => matchTownVillage(row))
+const {
+  page,
+  pageSize,
+  total,
+  list: pagedList,
+  loading,
+  resetAndLoad: loadList,
+} = useServerPagination<VillageMonthlyBatch>(async (pageNum, pageSize) => {
+  const town = isAdmin.value
+    ? filterTown.value || undefined
+    : userStore.roleInfo?.allData
+      ? undefined
+      : userStore.roleInfo?.town
+  return fetchVillageMonthlyBatchesPage({ town, pageNum, pageSize })
 })
+const list = pagedList
 
-const { page, pageSize, total, pagedList, resetPage } = useClientPagination(filteredList)
 const {
   page: detailPage,
   pageSize: detailPageSize,
   total: detailTotal,
-  pagedList: pagedDetailPeople,
-  resetPage: resetDetailPage,
-} = useClientPagination(detailPeople)
+  list: pagedDetailPeople,
+  resetAndLoad: loadDetailPeople,
+} = useServerPagination<BatchMemberPerson>(async (pageNum, pageSize) => {
+  const row = currentRow.value
+  if (!row?.id) return { list: [], total: 0 }
+  const page = await fetchBatchMemberPeoplePage(row.parentBatchId || row.id, {
+    batchNo: row.batchNo,
+    town: row.town,
+    village: row.village,
+    pruneDefaults: false,
+    parentBatchId: row.parentBatchId,
+    pageNum,
+    pageSize,
+  })
+  detailPaymentSummary.value = paymentSummaryFromApi(page.summary)
+  return page
+})
 
 function applyFilter() {
-  resetPage()
+  void loadList()
 }
 
 function resetFilter() {
   resetTownVillage()
-  resetPage()
+  void loadList()
 }
 
 const detailTitle = computed(() => {
@@ -534,21 +574,17 @@ function isCurrentBatch(row: VillageMonthlyBatch) {
   return Boolean(currentBatchNo.value) && row.batchNo === currentBatchNo.value
 }
 
+watch(pagedDetailPeople, (rows) => {
+  detailPeople.value = rows
+})
+
 async function openDetail(row: VillageMonthlyBatch) {
   currentRow.value = row
   detailVisible.value = true
   detailLoading.value = true
   try {
-    detailPeople.value = row.id
-      ? await fetchBatchMemberPeople(row.parentBatchId || row.id, {
-          batchNo: row.batchNo,
-          town: row.town,
-          village: row.village,
-          pruneDefaults: false,
-          parentBatchId: row.parentBatchId,
-        })
-      : []
-    resetDetailPage()
+    await loadDetailPeople()
+    detailPeople.value = pagedDetailPeople.value
   } finally {
     detailLoading.value = false
   }
@@ -557,6 +593,7 @@ async function openDetail(row: VillageMonthlyBatch) {
 function onDetailClosed() {
   currentRow.value = null
   detailPeople.value = []
+  detailPaymentSummary.value = null
 }
 
 async function onSubmitLeader(row: VillageMonthlyBatch) {
@@ -711,19 +748,13 @@ async function onToggleCalcLock(row: VillageMonthlyBatch) {
 }
 
 async function loadData() {
-  loading.value = true
-  try {
-    const town = userStore.roleInfo?.allData ? undefined : userStore.roleInfo?.town
-    list.value = await fetchVillageMonthlyBatches(town)
-    pendingSpecialCount.value = await fetchPendingSpecialCount(town).catch(() => 0)
-    for (const row of list.value.slice(0, 20)) {
-      if (['待提交', '待审核', '已审核'].includes(row.status)) {
-        void refreshCalcLocked(row.id)
-      }
+  await loadList()
+  const town = userStore.roleInfo?.allData ? undefined : userStore.roleInfo?.town
+  pendingSpecialCount.value = await fetchPendingSpecialCount(town).catch(() => 0)
+  for (const row of list.value) {
+    if (['待提交', '待审核', '已审核'].includes(row.status)) {
+      void refreshCalcLocked(row.id)
     }
-    resetPage()
-  } finally {
-    loading.value = false
   }
 }
 

+ 144 - 40
src/views/reports/ReportsView.vue

@@ -699,13 +699,15 @@ import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import {
   exportReportSummary,
-  fetchAlerts,
-  fetchPeople,
+  fetchAlertsPage,
+  fetchPeoplePage,
   fetchReportBatches,
   fetchReportTopic,
+  fetchWorkbenchStats,
   fetchYoy,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
@@ -726,6 +728,22 @@ const userStore = useUserStore()
 const batches = ref<VillageBatch[]>([])
 const people = ref<Person[]>([])
 const alerts = ref<AlertItem[]>([])
+const peopleHeadcount = ref(0)
+const alertTotalCount = ref(0)
+const townQualifyRows = ref<
+  Array<{
+    townId?: string
+    peopleCount?: number
+    villageCount?: number
+    withLandCount?: number
+    withoutLandCount?: number
+    qualifiedCount?: number
+    amount?: number | string
+  }>
+>([])
+const villageChartRows = ref<Array<{ villageId?: string; peopleCount?: number; amount?: number | string }>>(
+  [],
+)
 const peopleLoading = ref(false)
 const exporting = ref(false)
 const topicLoading = ref(false)
@@ -825,7 +843,7 @@ const scopeTip = computed(() => {
 const statItems = computed(() => {
   if (isTown.value) {
     const villages = townVillageOptions.value
-    const totalPeople = people.value.length
+    const totalPeople = peopleHeadcount.value
     const batchCount = batches.value.length
     return [
       { label: '下属村数', value: villages.length, unit: '个' },
@@ -840,7 +858,7 @@ const statItems = computed(() => {
   }
   if (isSocial.value || isLeader.value) {
     const villages = townVillageOptions.value
-    const totalPeople = people.value.length
+    const totalPeople = peopleHeadcount.value
     const batchCount = batches.value.length
     const totalAmount = batches.value.reduce((sum, b) => sum + b.totalAmount, 0)
     return [
@@ -854,16 +872,16 @@ const statItems = computed(() => {
     const towns = new Set(people.value.map((p) => p.town))
     const villages = new Set(people.value.map((p) => `${p.town}-${p.village}`))
     return [
-      { label: '覆盖街镇', value: towns.size, unit: '个' },
-      { label: '覆盖村居', value: villages.size, unit: '个' },
+      { label: '覆盖街镇', value: towns.size || townQualifyRows.value.length, unit: '个' },
+      { label: '覆盖村居', value: villages.size || townQualifyRows.value.reduce((s, r) => s + Number(r.villageCount || 0), 0), unit: '个' },
       {
         label: '有土地退养',
-        value: people.value.filter((p) => p.retireQualifyType === '有土地退养').length,
+        value: townQualifyRows.value.reduce((s, r) => s + Number(r.withLandCount || 0), 0),
         unit: '人',
       },
       {
         label: '无土地有资格',
-        value: people.value.filter((p) => p.retireQualifyType === '无土地有资格').length,
+        value: townQualifyRows.value.reduce((s, r) => s + Number(r.withoutLandCount || 0), 0),
         unit: '人',
       },
     ]
@@ -872,16 +890,16 @@ const statItems = computed(() => {
     const towns = new Set(people.value.map((p) => p.town))
     const totalAmount = batches.value.reduce((sum, b) => sum + b.totalAmount, 0)
     return [
-      { label: '覆盖街镇', value: towns.size, unit: '个' },
-      { label: '人员合计', value: people.value.length, unit: '人' },
+      { label: '覆盖街镇', value: towns.size || townQualifyRows.value.length, unit: '个' },
+      { label: '人员合计', value: peopleHeadcount.value, unit: '人' },
       { label: '批次合计', value: batches.value.length, unit: '个' },
       { label: '预计发放总额', value: formatAmount(totalAmount), unit: '元' },
     ]
   }
   const scopeB = batches.value
-  const totalPeople = people.value.length
+  const totalPeople = peopleHeadcount.value
   const totalAmount = scopeB.reduce((sum, b) => sum + b.totalAmount, 0)
-  const alertCount = alerts.value.filter((a) => a.status !== '已关闭').length
+  const alertCount = alertTotalCount.value
   const scopeLabel = isDistrict.value ? '全区' : lockedTown.value || '本范围'
   return [
     { label: `${scopeLabel}可用人数`, value: totalPeople, unit: '人' },
@@ -907,6 +925,7 @@ const townSummary = computed(() => {
     }
   >()
   for (const b of batches.value) {
+    if (!b.town || b.town === '全区') continue
     const cur = map.get(b.town) || {
       batchCount: 0,
       peopleCount: 0,
@@ -921,12 +940,23 @@ const townSummary = computed(() => {
     cur.amount += b.totalAmount
     map.set(b.town, cur)
   }
-  for (const p of people.value) {
-    const cur = map.get(p.town)
-    if (!cur) continue
-    if (p.retireQualifyType === '有土地退养') cur.withLandCount += 1
-    if (p.retireQualifyType === '无土地有资格') cur.withoutLandCount += 1
-    if (p.status === '资格已核验' || p.retireQualifyType) cur.qualifiedCount += 1
+  for (const q of townQualifyRows.value) {
+    const name = regionName(String(q.townId || '')) || String(q.townId || '未知')
+    const cur = map.get(name) || {
+      batchCount: 0,
+      peopleCount: 0,
+      amount: 0,
+      alertCount: 0,
+      withLandCount: 0,
+      withoutLandCount: 0,
+      qualifiedCount: 0,
+    }
+    cur.peopleCount = Number(q.peopleCount || 0) || cur.peopleCount
+    cur.amount = Number(q.amount || 0) || cur.amount
+    cur.withLandCount = Number(q.withLandCount || 0)
+    cur.withoutLandCount = Number(q.withoutLandCount || 0)
+    cur.qualifiedCount = Number(q.qualifiedCount || 0)
+    map.set(name, cur)
   }
   for (const a of alerts.value) {
     if (a.status === '已关闭') continue
@@ -951,15 +981,15 @@ const villageSummary = computed(() => {
   const villages = townVillageOptions.value
   return villages.map((village) => {
     const villageBatches = batches.value.filter((b) => b.village === village)
-    const villagePeople = people.value.filter((p) => p.village === village)
+    const chart = villageChartRows.value.find((r) => regionName(String(r.villageId || '')) === village)
     return {
       village,
       batchCount: villageBatches.length,
-      peopleCount: villagePeople.length,
-      amount: villageBatches.reduce((sum, b) => sum + b.totalAmount, 0),
-      withLandCount: villagePeople.filter((p) => p.retireQualifyType === '有土地退养').length,
-      withoutLandCount: villagePeople.filter((p) => p.retireQualifyType === '无土地有资格').length,
-      qualifiedCount: villagePeople.filter((p) => p.status === '资格已核验').length,
+      peopleCount: Number(chart?.peopleCount || 0),
+      amount: Number(chart?.amount || 0) || villageBatches.reduce((sum, b) => sum + b.totalAmount, 0),
+      withLandCount: 0,
+      withoutLandCount: 0,
+      qualifiedCount: 0,
       alertCount: alerts.value.filter((a) => a.village === village && a.status !== '已关闭').length,
     }
   })
@@ -1001,11 +1031,9 @@ const adminVillageSummary = computed(() => {
 })
 
 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))])
-const insuranceStatusOptions = computed(() => [
-  ...new Set(people.value.map((p) => p.insuranceStatus).filter(Boolean)),
-])
+const peopleStatusOptions = ['待提交资格', '待资格审批', '新增', '待居保匹配', '正常发放', '暂停', '停止', '死亡丧葬']
+const peopleSourceOptions = computed(() => [...new Set(people.value.map((p) => p.source).filter(Boolean))])
+const insuranceStatusOptions = ['未匹配', '匹配通过', '匹配失败', '丧失资格']
 
 function includesText(source: string | undefined, keyword: string): boolean {
   if (!keyword) return true
@@ -1114,13 +1142,75 @@ const {
   resetPage: resetBatchPage,
 } = useClientPagination(() => filteredBatches.value)
 
+function peopleQueryTown() {
+  if (isTownScope.value) return lockedTown.value
+  if (isAgri.value || isHr.value || isAdminDistrict.value) return peopleQuery.town
+  return effectiveTown.value
+}
+
+function peopleQueryVillage() {
+  if (isTownScope.value) return peopleQuery.village || lockedVillage.value
+  if (isAgri.value || isHr.value || isAdminDistrict.value) return peopleQuery.village
+  return query.village
+}
+
+function peopleStatusToCode(s: string) {
+  const m: Record<string, string> = {
+    待提交资格: 'DRAFT',
+    待资格审批: 'PENDING_TOWN_APPROVE',
+    新增: 'NEW',
+    待居保匹配: 'PENDING_INSURANCE_MATCH',
+    正常发放: 'NORMAL',
+    正常在库: 'NORMAL',
+    暂停: 'PAUSED',
+    停止: 'STOPPED',
+    死亡丧葬: 'FUNERAL',
+    已归档: 'ARCHIVED',
+  }
+  return m[s]
+}
+
+function insuranceToCode(s: string) {
+  const m: Record<string, string> = {
+    未匹配: 'PENDING',
+    匹配通过: 'SUCCESS',
+    匹配失败: 'FAIL',
+    丧失资格: 'LOSE_ELIGIBILITY',
+  }
+  return m[s]
+}
+
 const {
   page: peoplePage,
   pageSize: peoplePageSize,
   total: peopleTotal,
-  pagedList: pagedPeople,
-  resetPage: resetPeoplePage,
-} = useClientPagination(() => filteredPeople.value)
+  list: pagedPeople,
+  resetAndLoad: resetPeoplePage,
+} = useServerPagination<Person>(
+  async (pageNum, pageSize) => {
+    const res = await fetchPeoplePage({
+      pageNum,
+      pageSize,
+      name: peopleQuery.name || undefined,
+      idCard: peopleQuery.idCard || undefined,
+      batchNo: peopleQuery.batchNo || query.batchNo || undefined,
+      town: peopleQueryTown() || undefined,
+      village: peopleQueryVillage() || undefined,
+      bizStatus: peopleStatusToCode(peopleQuery.status) || undefined,
+      source: peopleQuery.source || undefined,
+      insuranceMatchResult: insuranceToCode(peopleQuery.insuranceStatus) || undefined,
+      retireQualifyType:
+        peopleQuery.retireQualifyType === '有土地退养'
+          ? 'WITH_LAND'
+          : peopleQuery.retireQualifyType === '无土地有资格'
+            ? 'WITHOUT_LAND'
+            : undefined,
+    })
+    people.value = res.list
+    return res
+  },
+  { pageSize: 10 },
+)
 
 function applySharedFilter() {
   resetBatchPage()
@@ -1206,7 +1296,7 @@ function resetBatchQuery() {
 }
 
 function applyPeopleFilter() {
-  resetPeoplePage()
+  void resetPeoplePage()
 }
 
 function resetPeopleQuery() {
@@ -1390,19 +1480,33 @@ async function loadAll() {
   peopleLoading.value = true
   try {
     const role = userStore.roleInfo
-    const [batchData, peopleData, alertData] = await Promise.all([
+    const payMonth = (userStore.currentMonth || '').replace('-', '')
+    const [batchData, alertPage, wb] = await Promise.all([
       fetchReportBatches(),
-      fetchPeople(),
-      fetchAlerts(),
+      fetchAlertsPage({
+        pageNum: 1,
+        pageSize: 20,
+        town: lockedTown.value || undefined,
+        village: lockedVillage.value || undefined,
+      }),
+      fetchWorkbenchStats({
+        payMonth: payMonth || undefined,
+        town: lockedTown.value || undefined,
+        village: lockedVillage.value || undefined,
+        months: 1,
+        roleKey: role?.key,
+      }),
     ])
     batches.value = role ? filterByRoleScope(batchData, role) : batchData
-    people.value = role ? filterByRoleScope(peopleData, role) : peopleData
-    alerts.value = role ? filterByRoleScope(alertData, role) : alertData
+    alerts.value = role ? filterByRoleScope(alertPage.list, role) : alertPage.list
+    alertTotalCount.value = Number(alertPage.total || 0)
+    peopleHeadcount.value = Number(wb?.personnelCount || 0)
+    townQualifyRows.value = wb?.townQualify || []
+    villageChartRows.value = wb?.villageCharts || []
     resetTownSummaryPage()
     resetVillageSummaryPage()
     resetBatchPage()
-    resetPeoplePage()
-    await Promise.all([loadTopics(), loadYoy()])
+    await Promise.all([loadTopics(), loadYoy(), resetPeoplePage()])
   } finally {
     peopleLoading.value = false
   }

+ 54 - 71
src/views/special/SpecialBusinessView.vue

@@ -871,20 +871,20 @@ import {
   createSpecialBiz,
   fetchAdjustReissueRoster,
   fetchCurrentFuneralAmount,
-  fetchDeaths,
-  fetchPeople,
+  fetchDeathsPage,
+  fetchPeoplePage,
   fetchPersonDetail,
   fetchPersonnelTimeline,
   fetchRefundStats,
-  fetchSpecials,
+  fetchSpecialsPage,
   triggerAdjustReissue,
   uploadBizFile,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
-import { filterByRoleScope } from '@/utils/buildDashboard'
 import { formatAmount, maskIdCard } from '@/utils/mask'
 import {
   processActionLabelOf,
@@ -900,12 +900,8 @@ const activeTab = ref('death')
 const activeVillage = ref('')
 const adjustActiveVillage = ref('')
 const list = ref<SpecialBiz[]>([])
-const deaths = ref<DeathRecord[]>([])
-const people = ref<Person[]>([])
 const adjustRoster = ref<AdjustReissuePerson[]>([])
 const loading = ref(false)
-const deathLoading = ref(false)
-const peopleLoading = ref(false)
 const adjustLoading = ref(false)
 const adjustTriggering = ref(false)
 const saving = ref(false)
@@ -1045,11 +1041,9 @@ const adjustVillageTabs = computed(() => {
 function onAdminTownChange() {
   activeVillage.value = ''
   adjustActiveVillage.value = ''
-  resetPeoplePage()
-  resetDeathPage()
   resetAdjustRosterPage()
-  loadPeople()
-  loadDeaths()
+  void loadPeople()
+  void loadDeaths()
   loadAdjustRoster()
 }
 
@@ -1091,38 +1085,69 @@ const {
   page: deathPage,
   pageSize: deathPageSize,
   total: deathTotal,
-  pagedList: pagedDeaths,
-  resetPage: resetDeathPage,
-} = useClientPagination(() => {
-  const town = effectiveTown.value
-  if (!town) return deaths.value
-  // 接口已按镇过滤;名称缺失时仍展示,避免历史单被二次滤空
-  return deaths.value.filter((d) => !d.town || d.town === town)
+  list: pagedDeaths,
+  loading: deathLoading,
+  resetAndLoad: loadDeaths,
+} = useServerPagination<DeathRecord>(async (pageNum, pageSize) => {
+  const town = effectiveTown.value || undefined
+  return fetchDeathsPage({ town, pageNum, pageSize })
 })
+const deaths = pagedDeaths
 
 const {
   page: peoplePage,
   pageSize: peoplePageSize,
   total: peopleTotal,
-  pagedList: pagedPeople,
-  resetPage: resetPeoplePage,
-} = useClientPagination(() => villagePeople.value)
+  list: pagedPeople,
+  loading: peopleLoading,
+  resetAndLoad: loadPeople,
+} = useServerPagination<Person>(async (pageNum, pageSize) => {
+  const town = effectiveTown.value || userStore.roleInfo?.town
+  if (isAdmin.value && !town) return { list: [], total: 0 }
+  return fetchPeoplePage({
+    town: town || undefined,
+    village: activeVillage.value || undefined,
+    pageNum,
+    pageSize,
+  })
+})
+const people = pagedPeople
 
 const {
   page: funeralPage,
   pageSize: funeralPageSize,
   total: funeralTotal,
-  pagedList: pagedFuneral,
-  resetPage: resetFuneralPage,
-} = useClientPagination(() => funeralList.value)
+  list: pagedFuneral,
+  resetAndLoad: loadFuneral,
+} = useServerPagination<SpecialBiz>(async (pageNum, pageSize) => {
+  const town = effectiveTown.value || undefined
+  return fetchSpecialsPage({
+    town: !isAdmin.value || town ? town : undefined,
+    bizType: 'FUNERAL',
+    pageNum,
+    pageSize,
+  })
+})
 
 const {
   page: refundPage,
   pageSize: refundPageSize,
   total: refundTotal,
-  pagedList: pagedRefunds,
-  resetPage: resetRefundPage,
-} = useClientPagination(() => refundList.value)
+  list: pagedRefunds,
+  resetAndLoad: loadRefund,
+} = useServerPagination<SpecialBiz>(async (pageNum, pageSize) => {
+  const town = effectiveTown.value || undefined
+  return fetchSpecialsPage({
+    town: !isAdmin.value || town ? town : undefined,
+    bizType: 'REFUND',
+    pageNum,
+    pageSize,
+  })
+})
+
+async function loadSpecials() {
+  await Promise.all([loadFuneral(), loadRefund()])
+}
 
 const {
   page: adjustRosterPage,
@@ -1443,7 +1468,7 @@ watch(
 )
 
 function onVillageTabChange() {
-  resetPeoplePage()
+  void loadPeople()
 }
 
 function onAdjustVillageTabChange() {
@@ -1941,20 +1966,6 @@ async function onCreateSpecial() {
   }
 }
 
-async function loadSpecials() {
-  loading.value = true
-  try {
-    const town = effectiveTown.value || undefined
-    // 非管理员强制本镇;管理员有选镇则按镇拉,未选则全区
-    list.value = await fetchSpecials(
-      !isAdmin.value || town ? { town } : undefined,
-    )
-    resetFuneralPage()
-    resetRefundPage()
-  } finally {
-    loading.value = false
-  }
-}
 
 async function loadAdjustRoster() {
   adjustLoading.value = true
@@ -1971,34 +1982,6 @@ async function loadAdjustRoster() {
   }
 }
 
-async function loadDeaths() {
-  deathLoading.value = true
-  try {
-    const town = effectiveTown.value || undefined
-    deaths.value = await fetchDeaths(town ? { town } : undefined)
-    resetDeathPage()
-  } finally {
-    deathLoading.value = false
-  }
-}
-
-async function loadPeople() {
-  peopleLoading.value = true
-  try {
-    const town = effectiveTown.value || userStore.roleInfo?.town
-    // 按当前生效街镇拉取,避免管理员未选镇时看到全区混杂数据
-    const all = await fetchPeople(town ? { town } : undefined)
-    if (isAdmin.value) {
-      people.value = town ? all.filter((p) => p.town === town) : []
-    } else {
-      const role = userStore.roleInfo
-      people.value = role ? filterByRoleScope(all, role) : all
-    }
-    resetPeoplePage()
-  } finally {
-    peopleLoading.value = false
-  }
-}
 
 onMounted(async () => {
   // 管理员默认带上当前角色街镇,避免死亡列表未选镇时露出他镇数据

+ 5 - 7
src/views/status/StatusMaintenanceView.vue

@@ -271,21 +271,18 @@ function townHasCurrentYearStandard(town: string) {
 }
 
 const createBlockedTip = computed(() => {
-  if (isAdmin.value && !filterTown.value && !form.town) {
-    return '请先选择街镇后再新增本年度标准'
-  }
-  const town = isAdmin.value ? filterTown.value || form.town : currentTown.value
+  const town = isAdmin.value ? filterTown.value : currentTown.value
   if (town && townHasCurrentYearStandard(town)) {
     return `「${town}」${currentYear.value} 年度已有标准,禁止新增;请使用「调整标准」`
   }
   return ''
 })
 
-/** 列表页「新增标准」按钮:管理员需先选镇,且本年度尚无标准 */
+/** 列表页「新增标准」:管理员在「全部街镇」也可点,弹窗里再选镇 */
 const canOpenCreate = computed(() => {
   if (isAdmin.value) {
-    if (!filterTown.value) return false
-    return !townHasCurrentYearStandard(filterTown.value)
+    if (filterTown.value) return !townHasCurrentYearStandard(filterTown.value)
+    return true
   }
   return Boolean(currentTown.value) && !townHasCurrentYearStandard(currentTown.value)
 })
@@ -440,6 +437,7 @@ async function submitForm() {
       const res = await updateStandard({
         id: editingId.value,
         rawId: editing?.rawId,
+        town: form.town,
         monthly,
         funeral,
         date: form.date,

+ 189 - 49
src/views/system/SystemAdminView.vue

@@ -65,8 +65,8 @@
       <p class="page-tip">
         登录/登出审计(含客户端 IP)。账号与校验规则在此维护;不替代各镇资格审批与发放办理。
       </p>
-      <el-table v-loading="loginLoading" :data="pagedLoginLogs" border stripe size="small">
-        <el-table-column prop="time" label="时间" width="150" />
+      <el-table v-loading="loginLoading" :data="pagedLoginLogs" border stripe size="small" row-key="id">
+        <el-table-column prop="time" label="时间" width="170" />
         <el-table-column prop="user" label="用户" width="120" />
         <el-table-column label="动作" width="110">
           <template #default="{ row }">
@@ -94,17 +94,69 @@
           清理日志
         </el-button>
       </template>
-      <el-table v-loading="logLoading" :data="pagedLogs" border stripe size="small">
-        <el-table-column prop="time" label="操作时间" width="150" />
+      <div class="query-panel">
+        <el-form :inline="true" size="small">
+          <el-form-item label="操作时间">
+            <el-date-picker
+              v-model="logFilter.dateRange"
+              type="daterange"
+              unlink-panels
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              value-format="YYYY-MM-DD"
+              style="width: 260px"
+            />
+          </el-form-item>
+          <el-form-item label="操作人">
+            <el-input v-model="logFilter.operator" clearable placeholder="姓名/机构" style="width: 140px" />
+          </el-form-item>
+          <el-form-item label="角色">
+            <el-select v-model="logFilter.operatorRole" clearable placeholder="全部" style="width: 150px">
+              <el-option v-for="r in userStore.roles" :key="r.key" :label="r.label" :value="r.key" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="模块">
+            <el-select v-model="logFilter.bizType" clearable placeholder="全部" style="width: 160px">
+              <el-option v-for="m in opLogModuleOptions" :key="m.value" :label="m.label" :value="m.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="操作内容">
+            <el-select v-model="logFilter.action" clearable placeholder="全部" style="width: 170px">
+              <el-option v-for="a in opLogActionOptions" :key="a.value" :label="a.label" :value="a.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="结果">
+            <el-select v-model="logFilter.result" clearable placeholder="全部" style="width: 110px">
+              <el-option label="成功" value="成功" />
+              <el-option label="失败" value="失败" />
+              <el-option label="待处理" value="待处理" />
+            </el-select>
+          </el-form-item>
+          <el-form-item class="query-form-actions">
+            <el-button type="primary" @click="applyLogFilter">查询</el-button>
+            <el-button @click="resetLogFilter">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+      <el-table v-loading="logLoading" :data="pagedLogs" border stripe size="small" row-key="id">
+        <el-table-column prop="time" label="操作时间" width="170" />
         <el-table-column prop="user" label="操作人" width="100" />
-        <el-table-column prop="role" label="角色" width="120" />
-        <el-table-column prop="module" label="模块" width="120" />
+        <el-table-column prop="role" label="角色" width="130" />
+        <el-table-column prop="module" label="模块" width="140" show-overflow-tooltip />
         <el-table-column prop="action" label="操作内容" min-width="180" show-overflow-tooltip />
         <el-table-column label="结果" width="80" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.result" />
           </template>
         </el-table-column>
+        <el-table-column label="操作" width="80" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="openLogDetail(row as OpLog)">
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
       </el-table>
       <TablePagination
         v-model:page="logPage"
@@ -192,6 +244,26 @@
         <el-button type="primary" :loading="accountSaving" @click="onSaveAccount">保存</el-button>
       </template>
     </el-dialog>
+
+    <el-dialog v-model="logDetailVisible" title="操作日志详情" width="640px" destroy-on-close>
+      <el-descriptions v-if="logDetail" :column="2" border size="small">
+        <el-descriptions-item label="操作时间" :span="2">{{ logDetail.time || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="操作人">{{ logDetail.user || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="角色">{{ logDetail.role || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="机构" :span="2">{{ logDetail.operatorOrg || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="模块">{{ logDetail.module || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="操作内容">{{ logDetail.action || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="结果">{{ resultLabelOf(logDetail.result) || logDetail.result || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="流程阶段">{{ logDetail.stage || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="批次号">{{ logDetail.batchNo || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="人员ID">{{ logDetail.personnelId || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="意见" :span="2">{{ logDetail.opinion || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ logDetail.remarks || '—' }}</el-descriptions-item>
+        <el-descriptions-item v-if="logDetail.fileUrls" label="附件" :span="2">
+          {{ logDetail.fileUrls }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
   </div>
 </template>
 
@@ -207,8 +279,8 @@ import {
   exportOpLogs,
   factoryResetBusinessData,
   fetchAccounts,
-  fetchLoginLogs,
-  fetchOpLogs,
+  fetchLoginLogsPage,
+  fetchOpLogsPage,
   fetchValidateRules,
   resetAccountPassword,
   saveAccount,
@@ -216,6 +288,7 @@ import {
   setAccountStatus,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import { processActionLabelOf, resultLabelOf } from '@/utils/statusLabels'
@@ -223,11 +296,9 @@ import type { AccountItem, OpLog } from '@/types/biz'
 
 const userStore = useUserStore()
 const accounts = ref<AccountItem[]>([])
-const logs = ref<OpLog[]>([])
-const loginLogs = ref<OpLog[]>([])
 const accountLoading = ref(false)
-const logLoading = ref(false)
-const loginLoading = ref(false)
+const logDetailVisible = ref(false)
+const logDetail = ref<OpLog | null>(null)
 const rulesLoading = ref(false)
 const savingRules = ref(false)
 const resetting = ref(false)
@@ -261,29 +332,105 @@ const {
   resetPage: resetAccountPage,
 } = useClientPagination(accounts)
 
+const logFilter = reactive({
+  dateRange: null as [string, string] | null,
+  operator: '',
+  operatorRole: '',
+  bizType: '',
+  action: '',
+  result: '',
+})
+
+const opLogModuleOptions = [
+  { value: 'RETIRE_QUALIFY', label: '退养资格审批' },
+  { value: 'SPECIAL_BIZ', label: '特殊业务申请' },
+  { value: 'PERSONNEL_STATUS', label: '人员状态变更' },
+  { value: 'PERSONNEL_IMPORT', label: '人员导入' },
+  { value: 'PERSONNEL_FIELD_CHANGE', label: '人员字段变更' },
+  { value: 'INSURANCE_MATCH', label: '居保匹配' },
+  { value: 'TODO_READ', label: '待办已读' },
+  { value: 'CALC_ROUND', label: '测算轮次' },
+  { value: 'RETURN_ARREARS', label: '回盘失败待补发' },
+  { value: 'STANDARD_CHANGE', label: '标准变更' },
+  { value: 'STANDARD_ADJUST', label: '调标差额补发' },
+  { value: 'FUNERAL', label: '丧葬/死亡登记' },
+  { value: 'REFUND', label: '多发退款' },
+  { value: 'HEIR_UPDATE', label: '继承人信息变更' },
+]
+
+const opLogActionOptions = [
+  { value: 'QUALIFY_SUBMIT', label: '提交退养资格' },
+  { value: 'QUALIFY_PASS', label: '退养资格审批通过' },
+  { value: 'QUALIFY_REJECT', label: '退养资格审批驳回' },
+  { value: 'SPECIAL_APPROVE_PASS', label: '分管领导审批通过' },
+  { value: 'SPECIAL_APPROVE_REJECT', label: '分管领导审批驳回' },
+  { value: 'INSURANCE_MATCH_SUCCESS', label: '居保匹配通过' },
+  { value: 'INSURANCE_MATCH_FAIL', label: '居保匹配失败' },
+  { value: 'INSURANCE_MATCH_MANUAL', label: '居保人工确认' },
+  { value: 'TODO_READ', label: '待办已读' },
+  { value: 'PERSONNEL_IMPORT', label: '人员导入' },
+  { value: 'PERSONNEL_FIELD_CHANGE', label: '人员字段变更' },
+]
+
+function logFilterQuery() {
+  const range = logFilter.dateRange
+  return {
+    operator: logFilter.operator.trim() || undefined,
+    operatorRole: logFilter.operatorRole || undefined,
+    bizType: logFilter.bizType || undefined,
+    action: logFilter.action || undefined,
+    result: logFilter.result || undefined,
+    operateTimeFrom: range?.[0] ? new Date(`${range[0]}T00:00:00+08:00`).getTime() : undefined,
+    operateTimeTo: range?.[1] ? new Date(`${range[1]}T23:59:59.999+08:00`).getTime() : undefined,
+  }
+}
+
 const {
   page: logPage,
   pageSize: logPageSize,
   total: logTotal,
-  pagedList: pagedLogs,
-  resetPage: resetLogPage,
-} = useClientPagination(logs)
+  list: pagedLogs,
+  loading: logLoading,
+  resetAndLoad: loadLogs,
+} = useServerPagination<OpLog>(async (pageNum, pageSize) =>
+  fetchOpLogsPage({ pageNum, pageSize, ...logFilterQuery() }),
+)
+
+function applyLogFilter() {
+  void loadLogs()
+}
+
+function resetLogFilter() {
+  logFilter.dateRange = null
+  logFilter.operator = ''
+  logFilter.operatorRole = ''
+  logFilter.bizType = ''
+  logFilter.action = ''
+  logFilter.result = ''
+  void loadLogs()
+}
 
 const {
   page: loginPage,
   pageSize: loginPageSize,
   total: loginTotal,
-  pagedList: pagedLoginLogs,
-  resetPage: resetLoginPage,
-} = useClientPagination(loginLogs)
+  list: pagedLoginLogs,
+  loading: loginLoading,
+  resetAndLoad: loadLoginLogs,
+} = useServerPagination<OpLog>(async (pageNum, pageSize) => fetchLoginLogsPage({ pageNum, pageSize }))
 
 const overviewItems = computed(() => [
   { label: '系统账号', value: accounts.value.length, unit: '个' },
   { label: '启用账号', value: accounts.value.filter((a) => a.status === '启用').length, unit: '个' },
-  { label: '操作日志', value: logs.value.length, unit: '条' },
-  { label: '登录记录', value: loginLogs.value.length, unit: '条' },
+  { label: '操作日志', value: logTotal.value, unit: '条' },
+  { label: '登录记录', value: loginTotal.value, unit: '条' },
 ])
 
+function openLogDetail(row: OpLog) {
+  logDetail.value = row
+  logDetailVisible.value = true
+}
+
 function roleLabel(roleKey: string) {
   return userStore.roles.find((r) => r.key === roleKey)?.label ?? roleKey
 }
@@ -361,16 +508,20 @@ async function onExportOpLogs() {
 }
 
 async function onCleanupOpLogs() {
-  let days = 90
+  let days = 0
   try {
-    const { value } = await ElMessageBox.prompt('清理多少天前的操作日志?', '清理操作日志', {
-      confirmButtonText: '确认清理',
-      cancelButtonText: '取消',
-      inputValue: '90',
-    })
+    const { value } = await ElMessageBox.prompt(
+      '清理多少天前的操作日志?输入 0 表示清理全部(不含账号、配置、登录日志)。当天记录需填 0 才会删掉。',
+      '清理操作日志',
+      {
+        confirmButtonText: '确认清理',
+        cancelButtonText: '取消',
+        inputValue: '0',
+      },
+    )
     days = Number(value)
-    if (!Number.isFinite(days) || days <= 0) {
-      ElMessage.warning('请输入有效天数')
+    if (!Number.isFinite(days) || days < 0) {
+      ElMessage.warning('请输入 0 或正整数天数')
       return
     }
   } catch {
@@ -380,7 +531,16 @@ async function onCleanupOpLogs() {
   cleaningLogs.value = true
   try {
     const res = await cleanupOpLogs(days)
-    ElMessage.success(`已清理 ${res.removed ?? 0} 条操作日志`)
+    const removed = res.removed ?? 0
+    if (removed > 0) {
+      ElMessage.success(`已清理 ${removed} 条操作日志`)
+    } else {
+      ElMessage.warning(
+        days === 0
+          ? '没有可清理的操作日志'
+          : `没有早于 ${days} 天的可清理记录,当天日志请输入 0`,
+      )
+    }
     await loadLogs()
   } finally {
     cleaningLogs.value = false
@@ -449,26 +609,6 @@ async function loadAccounts() {
   }
 }
 
-async function loadLogs() {
-  logLoading.value = true
-  try {
-    logs.value = await fetchOpLogs()
-    resetLogPage()
-  } finally {
-    logLoading.value = false
-  }
-}
-
-async function loadLoginLogs() {
-  loginLoading.value = true
-  try {
-    loginLogs.value = await fetchLoginLogs()
-    resetLoginPage()
-  } finally {
-    loginLoading.value = false
-  }
-}
-
 async function loadValidateRules() {
   rulesLoading.value = true
   try {

+ 4 - 0
vite.config.ts

@@ -125,12 +125,16 @@ export default defineConfig(({ mode }) => {
         '/api': {
           target: apiTarget,
           changeOrigin: true,
+          timeout: 1_200_000,
+          proxyTimeout: 1_200_000,
           rewrite: (p) => `/sjnmtybt${p}`,
         },
         // 上传附件等相对路径(后端返回 /sjnmtybt/api/files/...)
         '/sjnmtybt': {
           target: apiTarget,
           changeOrigin: true,
+          timeout: 1_200_000,
+          proxyTimeout: 1_200_000,
         },
         // 认证登录(oauth)— 与 DMS/OAuth 网关一致
         '/oauth': {

Неке датотеке нису приказане због велике количине промена