Procházet zdrojové kódy

结合文档和会议进行调整。

DESKTOP-6LTVLN7\Liumouren před 1 měsícem
rodič
revize
402cca5442
39 změnil soubory, kde provedl 6173 přidání a 762 odebrání
  1. 3 3
      package.json
  2. 4 1
      public/config/config.js
  3. 321 48
      src/api/biz.ts
  4. 3 0
      src/api/mock/data.ts
  5. 185 16
      src/api/sjnmtybt/mappers.ts
  6. 817 107
      src/api/sjnmtybt/realBiz.ts
  7. 18 0
      src/api/sjnmtybt/regionStore.ts
  8. 1 0
      src/auto-imports.d.ts
  9. 7 0
      src/components.d.ts
  10. 86 0
      src/components/biz/AlertLifecycleTimeline.vue
  11. 245 11
      src/components/biz/BatchDetailDrawer.vue
  12. 117 0
      src/components/biz/FlowAttachments.vue
  13. 198 0
      src/components/biz/PersonStatusTimeline.vue
  14. 298 0
      src/components/biz/PersonnelDetailDescriptions.vue
  15. 215 0
      src/components/biz/PersonnelFormFields.vue
  16. 18 2
      src/components/common/StatusTag.vue
  17. 10 1
      src/composables/useClientPagination.ts
  18. 60 0
      src/composables/useServerPagination.ts
  19. 251 0
      src/constants/personnelFields.ts
  20. 123 6
      src/types/biz.ts
  21. 54 0
      src/utils/batchAmounts.ts
  22. 53 0
      src/utils/bizFiles.ts
  23. 32 0
      src/utils/fundShare.ts
  24. 23 0
      src/utils/request.ts
  25. 164 0
      src/utils/statusLabels.ts
  26. 1 1
      src/utils/statusTag.ts
  27. 31 0
      src/utils/validators.ts
  28. 36 5
      src/views/admin/AdminBatchesView.vue
  29. 138 2
      src/views/admin/PersonnelAdminView.vue
  30. 47 3
      src/views/admin/RegionsAdminView.vue
  31. 261 187
      src/views/alerts/AlertsView.vue
  32. 521 98
      src/views/approvals/ApprovalsView.vue
  33. 727 121
      src/views/base/BaseInfoView.vue
  34. 10 5
      src/views/disk/DiskReturnView.vue
  35. 164 19
      src/views/monthly/MonthlyPaymentView.vue
  36. 37 3
      src/views/reports/ReportsView.vue
  37. 725 92
      src/views/special/SpecialBusinessView.vue
  38. 159 24
      src/views/status/StatusMaintenanceView.vue
  39. 10 7
      vite.config.ts

+ 3 - 3
package.json

@@ -4,9 +4,9 @@
   "version": "0.0.0",
   "type": "module",
   "scripts": {
-    "dev": "vite --mode online",
-    "dev:online": "vite --mode online",
-    "dev:local": "vite --mode local",
+    "dev": "vite --mode backend-online",
+    "dev:online": "vite --mode backend-online",
+    "dev:local": "vite --mode backend-local",
     "build": "vue-tsc -b && vite build",
     "preview": "vite preview"
   },

+ 4 - 1
public/config/config.js

@@ -16,7 +16,10 @@ window.AppConfig = {
   systemSubtitle: '发放管理平台',
   // 请求超时(毫秒)
   requestTimeout: 15000,
-  // 业务 API 前缀(开发默认 /api,由 Vite 代理到 http://localhost:8088/sjnmtybt)
+  // 业务 API 前缀(部署后可改,无需重打包前端 JS):
+  // - 2130 静态站:用 '/api'(该端口已反代 /api → 后端;勿用 /sjnmtybt/api,会被 SPA 回成 index.html)
+  // - Tomcat 同域 /sjnmtybt:用 '/sjnmtybt/api'(须 Nginx 反代该路径到 WAR,不能 try_files 到前端)
+  // - 跨域直连:'http://121.43.55.7:11091/sjnmtybt/api'(需后端 CORS)
   apiBase: '/api',
   // 必须为 false:业务数据统一走 sjnmtybt-server → DMS,禁止前端 mock 造假
   useMock: false,

+ 321 - 48
src/api/biz.ts

@@ -1,6 +1,8 @@
+import { ElMessage } from 'element-plus'
 import { getAppConfig } from '@/utils/appConfig'
 import { DISK_RETURN_FAIL_REASONS } from '@/constants/diskReturn'
 import type {
+  BatchMemberPerson,
   HandleAlertPayload,
   Person,
   VillageBatch,
@@ -71,10 +73,130 @@ export function fetchPeople(params?: {
   return real.fetchPeople(params)
 }
 
+export function fetchPeoplePage(params?: real.PeoplePageParams) {
+  if (useMock()) {
+    let list = [...mockPeople]
+    if (params?.town) list = list.filter((p) => p.town === params.town)
+    if (params?.village) list = list.filter((p) => p.village === params.village)
+    if (params?.name) list = list.filter((p) => p.name?.includes(params.name!))
+    if (params?.idCard) list = list.filter((p) => p.idCard?.includes(params.idCard!))
+    if (params?.phone) {
+      list = list.filter((p) => String((p as Person).phoneNumber || '').includes(params.phone!))
+    }
+    if (params?.source) list = list.filter((p) => String(p.source || '').includes(params.source!))
+    if (params?.bizStatus) {
+      const code = String(params.bizStatus).toUpperCase()
+      list = list.filter(
+        (p) =>
+          String((p as Person).bizStatus || '').toUpperCase() === code ||
+          (code === 'NORMAL' && p.status === '正常在库') ||
+          (code === 'PAUSED' && p.status === '暂停') ||
+          (code === 'STOPPED' && p.status === '停止') ||
+          (code === 'FUNERAL' && String(p.status).includes('死亡')),
+      )
+    }
+    const pageNum = params?.pageNum || 1
+    const pageSize = params?.pageSize || 10
+    const start = (pageNum - 1) * pageSize
+    return delay({
+      list: list.slice(start, start + pageSize),
+      total: list.length,
+      pageNum,
+      pageSize,
+    })
+  }
+  return real.fetchPeoplePage(params)
+}
+
+export function downloadPersonnelImportTemplate(town?: string, village?: string) {
+  if (useMock()) {
+    ElMessage.info('模拟模式:请切换联调后下载正式模板')
+    return delay(undefined)
+  }
+  return real.downloadPersonnelImportTemplate(town, village)
+}
+
+export function importPersonnelExcel(file: File, town?: string, village?: string) {
+  if (useMock()) {
+    ElMessage.success('模拟模式:已假装导入成功')
+    return delay({ success: 0, fail: 0 })
+  }
+  return real.importPersonnelExcel(file, town, village)
+}
+
+export function fetchAlertDetail(alertId: string) {
+  if (useMock()) {
+    const hit = mockAlerts.find((a) => String(a.id) === String(alertId))
+    return delay(
+      hit
+        ? {
+            ...hit,
+            lifecycle: [
+              {
+                actionLabel: '产生告警',
+                status: '待处理',
+                operator: '系统',
+                result: hit.content,
+                operateTime: hit.createdAt,
+              },
+              ...(hit.status === '已关闭'
+                ? [
+                    {
+                      actionLabel: '关闭告警',
+                      status: '已关闭',
+                      operator: '业务部门',
+                      result: hit.result || '已核实并处理',
+                      operateTime: hit.createdAt,
+                    },
+                  ]
+                : []),
+            ],
+          }
+        : null,
+    )
+  }
+  return real.fetchAlertDetail(alertId)
+}
+
+/** 确保本村本月批次;草稿/退回时纠正误标「待镇初审」人员 */
+export function ensureVillageMonthBatch(town?: string, village?: string, payMonth?: string) {
+  if (useMock()) return delay(undefined)
+  return real.ensureVillageMonthBatch(town, village, payMonth)
+}
+
+export function fetchPersonDetail(personnelId: string) {
+  if (useMock()) {
+    const hit = mockPeople.find((p) => String(p.id) === String(personnelId))
+    return delay(hit ? { ...hit } : null)
+  }
+  return real.fetchPersonDetail(personnelId)
+}
+
 export function savePerson(payload: Partial<Person>) {
   if (useMock()) {
-    const id = Math.max(0, ...mockPeople.map((p) => Number(p.id) || 0)) + 1
     const month = getAppConfig().currentMonth
+    if (payload.id != null && payload.id !== '') {
+      const hit = mockPeople.find((p) => String(p.id) === String(payload.id))
+      if (hit) {
+        Object.assign(hit, {
+          ...payload,
+          name: payload.name ?? hit.name,
+          idCard: payload.idCard ?? hit.idCard,
+          town: payload.town ?? hit.town,
+          village: payload.village ?? hit.village,
+          bankCard: payload.bankCard ?? hit.bankCard,
+          bankName: payload.bankName ?? hit.bankName,
+          socialInsuranceCode: payload.socialInsuranceCode ?? hit.socialInsuranceCode,
+          standard: payload.standard ?? hit.standard,
+          startMonth: payload.startMonth ?? hit.startMonth,
+          remark: payload.remark ?? hit.remark,
+          retireQualifyType: payload.retireQualifyType ?? hit.retireQualifyType,
+          source: payload.source ?? hit.source,
+        })
+        return delay({ ok: true })
+      }
+    }
+    const id = Math.max(0, ...mockPeople.map((p) => Number(p.id) || 0)) + 1
     mockPeople.push({
       id,
       batchNo: month.replace('-', ''),
@@ -91,6 +213,7 @@ export function savePerson(payload: Partial<Person>) {
       source: '村合作社录入',
       insuranceStatus: '未匹配',
       remark: payload.remark,
+      retireQualifyType: payload.retireQualifyType,
     })
     return delay({ ok: true })
   }
@@ -136,19 +259,75 @@ export function fetchBatchMemberPeople(
 ) {
   if (useMock()) {
     const batch = mockVillageBatches.find((b) => b.id === batchId)
-    if (!batch) return delay([] as Person[])
-    const list = mockPeople.filter(
-      (p) =>
-        p.town === batch.town &&
-        p.village === batch.village &&
-        p.batchNo === batch.batchNo &&
-        !['暂停', '停止', '死亡丧葬', '已归档'].includes(p.status),
-    )
+    if (!batch) return delay([] as BatchMemberPerson[])
+    const list = mockPeople
+      .filter(
+        (p) =>
+          p.town === batch.town &&
+          p.village === batch.village &&
+          p.batchNo === batch.batchNo &&
+          !['暂停', '停止', '死亡丧葬', '已归档'].includes(p.status),
+      )
+      .map((p) => mockAsBatchMember(p))
     return delay(list)
   }
   return real.fetchBatchMemberPeople(batchId, opts)
 }
 
+/** 本村发放历史(发放明细行;金额来自明细,非人员底数冗余标准) */
+export function fetchVillagePaymentHistory(params?: { town?: string; village?: string }) {
+  if (useMock()) {
+    let list = [...mockPeople]
+    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.map((p) => mockAsBatchMember(p)))
+  }
+  return real.fetchVillagePaymentHistory(params)
+}
+
+/** 指定人员历史发放明细 */
+export function fetchPersonnelPaymentHistory(personnelId: string) {
+  if (useMock()) {
+    const p = mockPeople.find((x) => String(x.id) === String(personnelId))
+    return delay(p ? [mockAsBatchMember(p)] : [])
+  }
+  return real.fetchPersonnelPaymentHistory(personnelId)
+}
+
+/** 指定人员告警/处理记录 */
+export function fetchPersonnelAlerts(params: {
+  personnelId?: string
+  personName?: string
+  town?: string
+  village?: string
+}) {
+  if (useMock()) {
+    return delay(
+      mockAlerts.filter(
+        (a) =>
+          (!params.personName || a.personName === params.personName) &&
+          (!params.town || a.town === params.town) &&
+          (!params.village || !a.village || a.village === params.village),
+      ),
+    )
+  }
+  return real.fetchPersonnelAlerts(params)
+}
+
+function mockAsBatchMember(p: Person): BatchMemberPerson {
+  const monthlyAmount = Number(p.standard || 0)
+  const supplementAmount = 0
+  return {
+    ...p,
+    monthlyAmount,
+    oneTimeAmount: 0,
+    supplementAmount,
+    funeralAmount: 0,
+    deductAmount: 0,
+    payableAmount: monthlyAmount + supplementAmount,
+  }
+}
+
 /** 村合作社提交批次 → 镇经发中心初审 */
 export function submitVillageBatch(batchId: string) {
   if (useMock()) {
@@ -237,6 +416,7 @@ export function fetchBatchTimeline(batchId: string) {
         batchNo: b.batchNo,
         stage: '人员新增录入',
         action: '创建本月村批',
+        actionLabel: '创建本月村批',
         operator: '系统',
         result: '成功',
         operateTime: nowStr(),
@@ -249,6 +429,7 @@ export function fetchBatchTimeline(batchId: string) {
               batchNo: b.batchNo,
               stage: b.stage,
               action: '提交/流转',
+              actionLabel: '提交/流转',
               operator: b.submitter || '经办人',
               result: '成功',
               operateTime: b.submittedAt,
@@ -260,6 +441,27 @@ export function fetchBatchTimeline(batchId: string) {
   return real.fetchBatchTimeline(batchId)
 }
 
+/** 人员状态变更 / 特殊业务时间线 */
+export function fetchPersonnelTimeline(personnelId: string | number) {
+  if (useMock()) {
+    const p = mockPeople.find((x) => String(x.id) === String(personnelId))
+    return delay([
+      {
+        id: `pl-${personnelId}-1`,
+        action: 'STATUS_NORMAL',
+        actionLabel: '纳入在册',
+        operator: '系统',
+        operatorRole: '业务部门',
+        opinion: p ? `${p.name} 基础信息建档` : '建档',
+        result: '成功',
+        operateTime: nowStr(),
+        remarks: p?.remark,
+      },
+    ] as import('@/types/biz').ProcessLogItem[])
+  }
+  return real.fetchPersonnelTimeline(String(personnelId))
+}
+
 /** 补充流程备注 */
 export function addBatchProcessNote(payload: {
   batchId: string
@@ -331,7 +533,7 @@ export function confirmInsuranceManual(personId: number | string) {
     if (p) {
       p.insuranceStatus = '匹配通过'
       p.status = '正常在库'
-      p.remark = (p.remark || '') + '(人工确认通过)'
+      p.remark = '人工确认匹配成功'
     }
     return delay({ ok: true })
   }
@@ -422,18 +624,20 @@ export function fetchApprovals(params?: {
 
 /**
  * 镇经发中心:核验新增人员退养资格
- * 将人员状态改为「资格已核验」,并记录有土地退养 / 无土地有资格
+ * 写入 retireQualifyType;人员状态仍跟 bizStatus(无资格除外)
  */
 export function reviewPersonQualify(payload: {
   personId: number | string
-  retireQualifyType: '有土地退养' | '无土地有资格'
+  retireQualifyType: '有土地退养' | '无土地有资格' | '无资格'
 }) {
   if (useMock()) {
     const p = mockPeople.find((x) => x.id === payload.personId)
     if (!p) return delay({ ok: false })
     p.retireQualifyType = payload.retireQualifyType
-    p.status = '资格已核验'
-    if (payload.retireQualifyType === '无土地有资格') {
+    if (payload.retireQualifyType === '无资格') {
+      p.status = '无资格'
+      p.remark = [p.remark, '无资格;不具备土地退养补助资格'].filter(Boolean).join(';')
+    } else if (payload.retireQualifyType === '无土地有资格') {
       p.remark = p.remark?.includes('无土地')
         ? p.remark
         : [p.remark, '无承包地,按政策认定具备退养补助资格'].filter(Boolean).join(';')
@@ -447,6 +651,7 @@ export function decideApproval(payload: {
   id: string
   pass: boolean
   opinion?: string
+  fileUrls?: string
 }) {
   if (useMock()) {
     const appr = mockApprovals.find((a) => a.id === payload.id)
@@ -520,6 +725,19 @@ export function fetchStandards(town?: string) {
   return real.fetchStandards(town)
 }
 
+/** 本镇当前有效丧葬补贴金额(元) */
+export async function fetchCurrentFuneralAmount(town: string): Promise<number | null> {
+  if (useMock()) {
+    const list = town ? mockStandards.filter((s) => s.town === town) : [...mockStandards]
+    const sorted = [...list].sort((a, b) =>
+      b.year !== a.year ? b.year - a.year : b.date.localeCompare(a.date),
+    )
+    const hit = sorted.find((s) => s.funeral > 0)
+    return delay(hit ? hit.funeral : null)
+  }
+  return real.fetchCurrentFuneralAmount(town)
+}
+
 /** 新增年度补助标准 */
 export function createStandard(payload: {
   town: string
@@ -548,6 +766,7 @@ export function updateStandard(payload: {
   funeral: number
   date: string
   reason: string
+  rawId?: string
 }) {
   if (useMock()) {
     const row = mockStandards.find((s) => s.id === payload.id)
@@ -561,6 +780,17 @@ export function updateStandard(payload: {
   return real.updateStandard(payload)
 }
 
+/** 管理员删除补助标准 */
+export function deleteStandard(payload: { id: number; rawId?: string }) {
+  if (useMock()) {
+    const idx = mockStandards.findIndex((s) => s.id === payload.id)
+    if (idx < 0) return delay({ ok: false as const, message: '未找到该标准记录' })
+    mockStandards.splice(idx, 1)
+    return delay({ ok: true as const, message: '已删除' })
+  }
+  return real.deleteStandard(payload)
+}
+
 /** 死亡状态更新(业务部门) */
 export function updateDeathStatus(deathId: string) {
   if (useMock()) {
@@ -586,6 +816,7 @@ export function applyPersonStatusAction(payload: {
   /** 补发金额,可为负数 */
   amount?: number
   reason?: string
+  fileUrls?: string
 }) {
   if (useMock()) {
     const p = mockPeople.find((x) => x.id === payload.personId)
@@ -609,46 +840,70 @@ export function applyPersonStatusAction(payload: {
 }
 
 /* ========== 特殊业务 ========== */
-export function fetchSpecials() {
-  if (useMock()) return delay([...mockSpecials])
-  return real.fetchSpecials()
+export function fetchSpecials(params?: { town?: string }) {
+  if (useMock()) {
+    const list = params?.town
+      ? mockSpecials.filter((s) => s.town === params.town)
+      : [...mockSpecials]
+    return delay(list)
+  }
+  return real.fetchSpecials(params)
+}
+
+/** 特殊业务审批详情:关联人员 */
+export function fetchSpecialBizPerson(personnelId: string) {
+  if (useMock()) {
+    const p = mockPeople.find((x) => String(x.id) === String(personnelId))
+    return delay(p || null)
+  }
+  return real.fetchSpecialBizPerson(personnelId)
 }
 
-export function fetchDeaths() {
-  if (useMock()) return delay([...mockDeaths])
-  return real.fetchDeaths()
+export function fetchDeaths(params?: { town?: string }) {
+  if (useMock()) {
+    const list = params?.town
+      ? mockDeaths.filter((d) => d.town === params.town)
+      : [...mockDeaths]
+    return delay(list)
+  }
+  return real.fetchDeaths(params)
 }
 
-/** 镇社区事务中心:死亡人员信息录入 → 触发系统告警 */
+/** 镇社区事务中心:死亡人员信息录入 → 提交分管领导审批(通过后改人员状态) */
 export function createDeathRecord(payload: {
+  personId?: string | number
   personName: string
   idCard: string
   town: string
   village: string
   deathDate: string
   heirName?: string
+  heirRelation?: string
   heirIdCard?: string
   heirBankCard?: string
   heirContact?: string
+  fileUrls?: string
 }) {
   if (useMock()) {
     const dup = mockDeaths.find(
       (d) =>
-        d.idCard === payload.idCard ||
-        (d.personName === payload.personName &&
-          d.town === payload.town &&
-          d.village === payload.village),
+        (d.status === '待分管领导审批' || d.status === '待死亡状态更新') &&
+        (d.idCard === payload.idCard ||
+          (d.personName === payload.personName &&
+            d.town === payload.town &&
+            d.village === payload.village)),
     )
     if (dup) {
       return delay({
         ok: false,
-        message: '死亡信息同一周期内不可重复录入',
+        message: '该人员已有待审批的死亡登记,请勿重复提交',
         code: 'DUPLICATE_DEATH',
       })
     }
-    const id = `DT${Date.now()}`
+    const id = `SP${Date.now()}`
     mockDeaths.unshift({
       id,
+      personnelId: payload.personId != null ? String(payload.personId) : undefined,
       personName: payload.personName,
       idCard: payload.idCard,
       town: payload.town,
@@ -659,42 +914,34 @@ export function createDeathRecord(payload: {
       heirBankCard: payload.heirBankCard || '',
       heirContact: payload.heirContact || '',
       source: '社区事务受理服务中心手工录入',
-      status: '待死亡状态更新',
-      createdAt: nowStr(),
-    })
-    mockAlerts.unshift({
-      id: `AL${Date.now()}`,
-      batchNo: getAppConfig().currentMonth.replace('-', ''),
-      personName: payload.personName,
-      town: payload.town,
-      village: payload.village,
-      type: '民政死亡比对',
-      level: '高',
-      content: `死亡人员信息已录入,系统告警:请业务部门完成死亡状态更新并停止发放(身份证 ${payload.idCard.slice(-4)}${
-        payload.heirName ? `,继承人 ${payload.heirName}` : ''
-      })`,
-      status: '待处理',
+      status: '待分管领导审批',
       createdAt: nowStr(),
-      idCard: payload.idCard,
-      heirName: payload.heirName || '',
-      heirIdCard: payload.heirIdCard || '',
-      heirBankCard: payload.heirBankCard || '',
-      heirContact: payload.heirContact || '',
+      fileUrls: payload.fileUrls,
+      reason: `死亡登记;死亡日期 ${payload.deathDate}`,
     })
     return delay({ ok: true, id })
   }
   return real.createDeathRecord(payload)
 }
 
+export function uploadBizFile(file: File) {
+  if (useMock()) {
+    return delay({ url: `mock://${file.name}`, name: file.name })
+  }
+  return real.uploadBizFile(file)
+}
+
 /** 发起一般特殊业务 / 丧葬补贴 */
 export function createSpecialBiz(payload: {
   type: string
+  personId?: string | number
   personName: string
   town: string
   village: string
   reason: string
   amount: number
   heirName?: string
+  fileUrls?: string
 }) {
   if (useMock()) {
     const month = getAppConfig().currentMonth
@@ -710,6 +957,7 @@ export function createSpecialBiz(payload: {
       amount: payload.amount,
       status: '待分管领导审批',
       heirName: payload.heirName,
+      fileUrls: payload.fileUrls,
       createdAt: nowStr(),
     })
     mockApprovals.unshift({
@@ -1329,12 +1577,13 @@ export function archiveToNextMonth(id?: string) {
       (id && mockDisks.find((d) => d.id === id)) ||
       mockDisks.find((d) => d.status === '已回盘')
     if (row) {
+      row.status = '已归档'
       const monthly = mockMonthlyBatches.find(
         (m) => m.batchNo === row.batchNo && m.town === row.town,
       )
       if (monthly) monthly.status = '已归档'
       const batches = mockVillageBatches.filter(
-        (b) => b.batchNo === row.batchNo && b.town === row.town,
+        (b) => b.batchNo === row.batchNo && mTownVillageMatch(b, row),
       )
       for (const b of batches) {
         if (b.stage === '回盘信息确认') b.stage = '已归档'
@@ -1345,6 +1594,13 @@ export function archiveToNextMonth(id?: string) {
   return real.archiveToNextMonth(id)
 }
 
+function mTownVillageMatch(
+  b: { town: string; village?: string },
+  row: { town: string; village?: string },
+) {
+  return b.town === row.town && (!row.village || b.village === row.village)
+}
+
 /* ========== 告警 ========== */
 export function fetchAlerts() {
   if (useMock()) return delay([...mockAlerts])
@@ -1394,6 +1650,21 @@ export function handleAlert(payload: HandleAlertPayload | string, result?: strin
           : data.heirName
             ? `已核实人员信息并登记继承人 ${data.heirName}`
             : '已核实并处理人员信息')
+      a.remarks = data.remarks || a.remarks
+      a.fileUrls = data.fileUrls || a.fileUrls
+      const prev = Array.isArray(a.lifecycle) ? a.lifecycle : []
+      a.lifecycle = [
+        ...prev,
+        {
+          actionLabel: '关闭告警',
+          status: '已关闭',
+          operator: getAppConfig().roles.find((r) => r.key === getAppConfig().currentRole)?.label || '业务部门',
+          result: a.result,
+          remark: data.remarks,
+          fileUrls: data.fileUrls,
+          operateTime: nowStr(),
+        },
+      ]
 
       // 同步更新关联人员档案(按原姓名+街镇+村居匹配)
       const person = mockPeople.find(
@@ -1466,6 +1737,8 @@ export function saveRegionAdmin(payload: {
   defaultBank?: string
   enabled?: boolean
   remarks?: string
+  districtSharePercent?: number
+  townSharePercent?: number
 }) {
   if (useMock()) return delay({ ok: true as const })
   return real.saveRegionAdmin(payload)

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

@@ -504,6 +504,7 @@ export const mockDisks: DiskRecord[] = [
     id: 'DP202607YX',
     batchNo: '202607',
     town: '叶榭镇',
+    village: '井凌桥村',
     fileName: '202607-叶榭镇月补贴清单.xlsx',
     peopleCount: 154,
     amount: 197120,
@@ -516,6 +517,7 @@ export const mockDisks: DiskRecord[] = [
     id: 'DP202608YX',
     batchNo: '202608',
     town: '叶榭镇',
+    village: '叶榭村',
     fileName: '202608-叶榭镇月补贴清单.xlsx',
     peopleCount: 156,
     amount: 204400,
@@ -527,6 +529,7 @@ export const mockDisks: DiskRecord[] = [
     id: 'DP202608CD',
     batchNo: '202608',
     town: '车墩镇',
+    village: '车墩村',
     fileName: '202608-车墩镇月补贴发放文件.xlsx',
     peopleCount: 5,
     amount: 6300,

+ 185 - 16
src/api/sjnmtybt/mappers.ts

@@ -1,8 +1,10 @@
 /**
  * 后端 VO ↔ 新前端页面展示模型映射(不新增页面字段,只做值转换)
  */
+import { labelOf } from '@/utils/statusLabels'
 import type {
   AlertItem,
+  AlertLifecycleItem,
   DiskFailPerson,
   DiskRecord,
   Person,
@@ -40,7 +42,10 @@ const BATCH_STATUS_TO_MONTHLY: Record<string, string> = {
 
 const PERSON_STATUS_LABEL: Record<string, string> = {
   DRAFT: '草稿',
-  PENDING_TOWN_APPROVE: '待镇审批',
+  // 与批次「待镇经发初审」区分:人员侧表示随村材料待镇审
+  PENDING_TOWN_APPROVE: '待镇初审',
+  // 兼容旧文案/字典「待镇审批」
+  待镇审批: '待镇初审',
   PENDING_INSURANCE_MATCH: '待居保匹配',
   NORMAL: '正常在库',
   PAUSED: '暂停',
@@ -70,6 +75,21 @@ const ALERT_LEVEL_LABEL: Record<string, string> = {
   LOW: '低',
 }
 
+/** 后端 AlertTypeEnum → 页面中文(勿直接展示 code) */
+const ALERT_TYPE_LABEL: Record<string, string> = {
+  BANK_CARD_ABNORMAL: '银行卡异常',
+  NAME_MISMATCH: '姓名不一致',
+  INSURANCE_MATCH_FAIL: '居保匹配失败',
+  AMOUNT_ABNORMAL: '金额异常',
+  DEATH_STATUS: '死亡停发预警',
+  MATERIAL_PENDING: '材料待补',
+  RETURN_FAIL: '回盘失败',
+  LAND_MATCH_FAIL: '土地匹配不符合',
+  // 兼容历史/演示文案
+  民政死亡比对: '死亡停发预警',
+  回盘发放失败: '回盘失败',
+}
+
 const SPECIAL_TYPE_LABEL: Record<string, string> = {
   FUNERAL: '丧葬补贴',
   PAUSE: '暂停发放',
@@ -77,7 +97,7 @@ const SPECIAL_TYPE_LABEL: Record<string, string> = {
   RESUME: '恢复发放',
   STANDARD_ADJUST: '调标差额补发',
   SUPPLEMENT: '纳入下月补发',
-  REFUND: '退款',
+  REFUND: '多发退款',
   HEIR_UPDATE: '继承人信息变更',
 }
 
@@ -94,6 +114,8 @@ const SPECIAL_TYPE_CODE: Record<string, string> = {
   调标补发: 'STANDARD_ADJUST',
   纳入下月补发: 'SUPPLEMENT',
   补发: 'SUPPLEMENT',
+  多发退款: 'REFUND',
+  退款: 'REFUND',
 }
 
 /** 发放年月形态:20 开头 6 位(含调标月键如 202699) */
@@ -147,20 +169,28 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
   const remarks = String(raw.remarks || '')
   const qualifyCode = String(raw.retireQualifyType || '').toUpperCase()
   let retireQualifyType: Person['retireQualifyType']
-  if (qualifyCode === 'WITHOUT_LAND' || qualifyCode === '无土地有资格') {
+  if (qualifyCode === 'INELIGIBLE' || qualifyCode === '无资格') {
+    retireQualifyType = '无资格'
+  } else if (qualifyCode === 'WITHOUT_LAND' || qualifyCode === '无土地有资格') {
     retireQualifyType = '无土地有资格'
   } else if (qualifyCode === 'WITH_LAND' || qualifyCode === '有土地退养') {
     retireQualifyType = '有土地退养'
+  } else if (remarks.includes('INELIGIBLE') || remarks.includes('无资格') || remarks.includes('不具备退养')) {
+    retireQualifyType = '无资格'
   } else if (remarks.includes('无土地') || remarks.includes('无承包地')) {
     retireQualifyType = '无土地有资格'
   } else if (remarks.includes('有土地') || remarks.includes('资格已核验')) {
     retireQualifyType = '有土地退养'
   }
-  // 已核验资格时,列表状态展示为「资格已核验」(业务状态仍可能是 NORMAL)
-  if (retireQualifyType && (status === '正常在库' || status === '草稿' || !status)) {
-    status = '资格已核验'
+  // 人员状态一律跟 bizStatus,各页一致;退养资格用 retireQualifyType 单独展示
+  // 仅「无资格」在业务上等同异常终态,统一展示为「无资格」
+  if (retireQualifyType === '无资格') {
+    status = '无资格'
   }
 
+  const numOrU = (v: unknown) =>
+    v == null || v === '' ? undefined : Number(v)
+
   return {
     id: String(raw.id || '') as unknown as number,
     batchNo: batchNoHint || '',
@@ -182,6 +212,45 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     insuranceStatus: INSURANCE_LABEL[ins] || String(raw.insuranceStatus || '未匹配'),
     remark: remarks || undefined,
     retireQualifyType,
+    phoneNumber: raw.phoneNumber != null ? String(raw.phoneNumber) : undefined,
+    address: raw.address != null ? String(raw.address) : undefined,
+    householdType: raw.householdType != null ? String(raw.householdType) : undefined,
+    districtId: raw.districtId != null ? String(raw.districtId) : undefined,
+    townId: raw.townId != null ? String(raw.townId) : undefined,
+    villageId: raw.villageId != null ? String(raw.villageId) : undefined,
+    villageCode: raw.villageCode != null ? String(raw.villageCode) : undefined,
+    entrustPlanCode: raw.entrustPlanCode != null ? String(raw.entrustPlanCode) : undefined,
+    entrustPlanName: raw.entrustPlanName != null ? String(raw.entrustPlanName) : undefined,
+    payeeName: raw.payeeName != null ? String(raw.payeeName) : undefined,
+    bankType: raw.bankType != null ? String(raw.bankType) : undefined,
+    bankBranchCode: raw.bankBranchCode != null ? String(raw.bankBranchCode) : undefined,
+    enjoyEndMonth: payMonthToMonth(String(raw.enjoyEndMonth || '')) || undefined,
+    supplementStartMonth: payMonthToMonth(String(raw.supplementStartMonth || '')) || undefined,
+    nextPayStartMonth: payMonthToMonth(String(raw.nextPayStartMonth || '')) || undefined,
+    baseAmount: numOrU(raw.baseAmount),
+    oneTimeAmount: numOrU(raw.oneTimeAmount),
+    insuranceMatchResult: ins || undefined,
+    bankCardConfirmStatus:
+      raw.bankCardConfirmStatus != null ? String(raw.bankCardConfirmStatus) : undefined,
+    bizStatus: biz || undefined,
+    payThisMonth:
+      raw.payThisMonth == null || raw.payThisMonth === ''
+        ? undefined
+        : raw.payThisMonth === true ||
+          raw.payThisMonth === 'true' ||
+          raw.payThisMonth === 1 ||
+          raw.payThisMonth === '1',
+    inputVillageId: raw.inputVillageId != null ? String(raw.inputVillageId) : undefined,
+    deathDate: raw.deathDate != null ? String(raw.deathDate).slice(0, 10) : undefined,
+    heirName: raw.heirName != null ? String(raw.heirName) : undefined,
+    heirRelation: raw.heirRelation != null ? String(raw.heirRelation) : undefined,
+    heirIdNumber: raw.heirIdNumber != null ? String(raw.heirIdNumber) : undefined,
+    heirBankCard: raw.heirBankCard != null ? String(raw.heirBankCard) : undefined,
+    heirPhone: raw.heirPhone != null ? String(raw.heirPhone) : undefined,
+    funeralDiffAmount: numOrU(raw.funeralDiffAmount),
+    landSubsidyIncrease: numOrU(raw.landSubsidyIncrease),
+    createTime: fmtTime(raw.createTime as number | string) || undefined,
+    updateTime: fmtTime(raw.updateTime as number | string) || undefined,
   }
 }
 
@@ -245,22 +314,83 @@ export function mapVillageBatch(raw: Record<string, unknown>): VillageBatch {
   }
 }
 
+const PROCESS_ACTION_LABEL: Record<string, string> = {
+  SPECIAL_BIZ: '特殊业务申请',
+  SPECIAL_APPROVE_PASS: '分管领导审批通过',
+  SPECIAL_APPROVE_REJECT: '分管领导审批驳回',
+  STATUS_STOPPED: '停止发放',
+  STATUS_PAUSED: '暂停发放',
+  STATUS_NORMAL: '恢复发放',
+  STATUS_FUNERAL: '死亡丧葬状态落地',
+  STATUS_ARCHIVED: '人员归档',
+  FUNERAL: '丧葬/死亡登记',
+  STOP: '停止发放申请',
+  PAUSE: '暂停发放申请',
+  RESUME: '恢复发放申请',
+  SUPPLEMENT: '补发申请',
+  NOTE: '补充备注',
+  INSURANCE_MATCH: '居保匹配',
+  INSURANCE_MATCH_SUCCESS: '居保匹配通过',
+  INSURANCE_MATCH_MANUAL: '居保人工确认',
+  INSURANCE_MATCH_FAIL: '居保匹配失败',
+  INSURANCE_MATCH_PENDING: '居保待确认',
+  INSURANCE_MATCH_SNAPSHOT: '居保匹配结果',
+}
+
+function stripProcessFileUrls(raw?: string): string | undefined {
+  if (!raw) return undefined
+  let s = raw.trim()
+  if (s.startsWith('FILES:')) s = s.slice('FILES:'.length)
+  const heir = s.indexOf('|HEIR:')
+  if (heir >= 0) s = s.slice(0, heir)
+  s = s.trim()
+  return s || undefined
+}
+
+function processActionLabel(
+  action: string,
+  bizType?: string,
+  status?: string,
+  opinion?: string,
+): string {
+  if (action === 'SPECIAL_BIZ') {
+    const st = status ? `(${status})` : ''
+    const op = opinion || ''
+    if (
+      bizType === 'FUNERAL' &&
+      (op.includes('死亡登记') || op.includes('死亡日期'))
+    ) {
+      return `死亡人员录入${st}`
+    }
+    const typeLabel = SPECIAL_TYPE_LABEL[bizType || ''] || bizType || '特殊业务'
+    return `${typeLabel}申请${st}`
+  }
+  return PROCESS_ACTION_LABEL[action] || action || '办理'
+}
+
 export function mapProcessLog(raw: Record<string, unknown>): ProcessLogItem {
+  const action = String(raw.action || raw.approveNode || '办理')
+  const bizType = raw.bizType ? String(raw.bizType) : undefined
+  const status = raw.status ? String(raw.status) : undefined
+  const opinion = raw.opinion ? String(raw.opinion) : undefined
   return {
     id: String(raw.id || ''),
     batchId: raw.batchId ? String(raw.batchId) : undefined,
     batchNo: raw.batchNo ? String(raw.batchNo) : undefined,
     stage: flowStageLabel(String(raw.stage || '')),
-    action: String(raw.action || raw.approveNode || '办理'),
+    action,
+    actionLabel: processActionLabel(action, bizType, status, opinion),
     approveNode: raw.approveNode ? String(raw.approveNode) : undefined,
     operator: String(raw.operator || raw.operatorRole || ''),
     operatorRole: raw.operatorRole ? String(raw.operatorRole) : undefined,
     operatorOrg: raw.operatorOrg ? String(raw.operatorOrg) : undefined,
-    opinion: raw.opinion ? String(raw.opinion) : undefined,
+    opinion,
     result: String(raw.result || raw.status || ''),
-    status: raw.status ? String(raw.status) : undefined,
+    status,
     operateTime: fmtTime(raw.operateTime as number),
-    fileUrls: raw.fileUrls ? String(raw.fileUrls) : undefined,
+    fileUrls: stripProcessFileUrls(raw.fileUrls ? String(raw.fileUrls) : undefined),
+    remarks: raw.remarks ? String(raw.remarks) : undefined,
+    bizType,
   }
 }
 
@@ -290,7 +420,13 @@ export function mapDisk(raw: Record<string, unknown>): DiskRecord {
   const returnStatusRaw = String(raw.returnStatus || '未回盘')
   let diskStatus = '待回盘'
   let returnStatus = '待确认'
-  if (status === 'RETURNED' || status === 'ARCHIVED') {
+  if (status === 'ARCHIVED') {
+    // 归档 = 本月业务闭环结束(与「已回盘」区分,避免点完归档界面无变化)
+    diskStatus = '已归档'
+    returnStatus = returnStatusRaw.includes('部分') || returnStatusRaw.includes('异常')
+      ? '部分失败'
+      : '成功'
+  } else if (status === 'RETURNED') {
     diskStatus = '已回盘'
     if (returnStatusRaw.includes('部分') || returnStatusRaw.includes('异常')) returnStatus = '部分失败'
     else if (returnStatusRaw.includes('成功') || returnStatusRaw === '全部成功') returnStatus = '成功'
@@ -303,6 +439,7 @@ export function mapDisk(raw: Record<string, unknown>): DiskRecord {
     id: String(raw.id || ''),
     batchNo: String(raw.batchNo || ''),
     town: regionName(String(raw.townId || '')),
+    village: regionName(String(raw.villageId || '')),
     fileName: String(raw.exportFileName || `${raw.batchNo || ''}-月补贴发放文件.xlsx`),
     peopleCount: Number(raw.peopleCount || 0),
     amount: Number(raw.totalAmount || 0),
@@ -340,22 +477,46 @@ function alertBatchLabel(raw: Record<string, unknown>): string {
   return m?.[1] || ''
 }
 
+export function mapAlertLifecycle(raw: unknown): AlertLifecycleItem[] {
+  if (!Array.isArray(raw)) return []
+  return raw.map((it) => {
+    const row = (it || {}) as Record<string, unknown>
+    return {
+      action: row.action ? String(row.action) : undefined,
+      actionLabel: row.actionLabel ? String(row.actionLabel) : undefined,
+      status: ALERT_STATUS_LABEL[String(row.status || '')] || (row.status ? String(row.status) : undefined),
+      operator: row.operator ? String(row.operator) : undefined,
+      result: row.result ? String(row.result) : undefined,
+      remark: row.remark ? String(row.remark) : undefined,
+      fileUrls: row.fileUrls ? String(row.fileUrls) : undefined,
+      operateTime: fmtTime(row.operateTime as number | string) || undefined,
+    }
+  })
+}
+
 export function mapAlert(raw: Record<string, unknown>): AlertItem {
   const hs = String(raw.handleStatus || '')
   const level = String(raw.level || '')
+  const typeCode = String(raw.type || '')
   return {
     id: String(raw.id || ''),
     batchNo: alertBatchLabel(raw),
     personName: String(raw.personnelName || ''),
+    personnelId: raw.personnelId ? String(raw.personnelId) : undefined,
     town: regionName(String(raw.townId || '')),
     village: regionName(String(raw.villageId || '')),
-    type: String(raw.type || ''),
+    type: ALERT_TYPE_LABEL[typeCode] || typeCode,
     level: ALERT_LEVEL_LABEL[level] || level,
     content: String(raw.content || ''),
     status: ALERT_STATUS_LABEL[hs] || hs || '待处理',
     result: raw.handleResult ? String(raw.handleResult) : undefined,
     createdAt: fmtTime(raw.createTime as number),
     failReason: raw.failReasonCode ? String(raw.failReasonCode) : undefined,
+    lifecycle: mapAlertLifecycle(raw.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,
   }
 }
 
@@ -365,7 +526,7 @@ export function mapSpecial(raw: Record<string, unknown>): SpecialBiz {
     id: String(raw.id || ''),
     batchNo: String(raw.batchNo || ''),
     town: regionName(String(raw.townId || '')),
-    village: '',
+    village: regionName(String(raw.villageId || '')),
     type: SPECIAL_TYPE_LABEL[bizType] || bizType,
     personName: String(raw.name || ''),
     reason: String(raw.reason || ''),
@@ -373,6 +534,7 @@ export function mapSpecial(raw: Record<string, unknown>): SpecialBiz {
     status: String(raw.approveStatus || ''),
     heirName: raw.heirName ? String(raw.heirName) : undefined,
     createdAt: fmtTime(raw.createTime as number),
+    fileUrls: raw.fileUrls ? String(raw.fileUrls) : undefined,
   }
 }
 
@@ -394,20 +556,27 @@ export function mapStandard(raw: Record<string, unknown>): SubsidyStandard {
     date = eff.length >= 10 ? eff.slice(0, 10) : eff
   }
   const year = date ? Number(date.slice(0, 4)) : new Date().getFullYear()
-  // id 可能是字符串,页面用 number 比较「是否最新」——用 hash
   const idStr = String(raw.id || '')
+  // 页面列表 key 仍要 number;用稳定哈希避免「字符码求和」撞号导致同镇多行被当成最新
   let idNum = Number(idStr)
-  if (!Number.isFinite(idNum)) {
-    idNum = [...idStr].reduce((s, c) => s + c.charCodeAt(0), 0)
+  if (!Number.isFinite(idNum) || idNum === 0) {
+    let h = 0
+    for (let i = 0; i < idStr.length; i++) {
+      h = (Math.imul(31, h) + idStr.charCodeAt(i)) | 0
+    }
+    idNum = Math.abs(h) || 1
   }
+  const cur = raw.current
   return {
     id: idNum,
+    rawId: idStr || undefined,
     town: String(raw.townName || regionName(String(raw.townId || ''))),
     year: Number.isFinite(year) ? year : new Date().getFullYear(),
     monthly: Number(raw.amount || 0),
     funeral: Number(raw.funeralAmount || 0),
     date,
     reason: String(raw.adjustReason || raw.remarks || ''),
+    current: cur === true || cur === 'true' || cur === 1 || cur === '1',
   }
 }
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 817 - 107
src/api/sjnmtybt/realBiz.ts


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

@@ -7,6 +7,14 @@ export interface RegionRow {
   name: string
   level?: string
   parentId?: string
+  entrustPlanCode?: string
+  defaultBank?: string
+  enabled?: boolean
+  remarks?: string
+  /** 区承担比例 % */
+  districtSharePercent?: number
+  /** 镇承担比例 % */
+  townSharePercent?: number
 }
 
 const FALLBACK_NAME: Record<string, string> = {
@@ -32,6 +40,16 @@ const idToName = new Map<string, string>()
 
 let townsLoaded = false
 
+/** 区划增删改后清空前端缓存,避免下拉仍是旧村居 */
+export function invalidateRegionCache() {
+  townsLoaded = false
+  towns.splice(0, towns.length)
+  villagesByTown.clear()
+  nameToTownId.clear()
+  nameToVillageId.clear()
+  idToName.clear()
+}
+
 function remember(row: RegionRow) {
   const name = (row.name && String(row.name).trim()) || FALLBACK_NAME[row.id] || row.id
   idToName.set(row.id, name)

+ 1 - 0
src/auto-imports.d.ts

@@ -7,6 +7,7 @@
 export {}
 declare global {
   const EffectScope: typeof import('vue')['EffectScope']
+  const ElMessage: typeof import('element-plus/es')['ElMessage']
   const computed: typeof import('vue')['computed']
   const createApp: typeof import('vue')['createApp']
   const customRef: typeof import('vue')['customRef']

+ 7 - 0
src/components.d.ts

@@ -8,6 +8,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    AlertLifecycleTimeline: typeof import('./components/biz/AlertLifecycleTimeline.vue')['default']
     BatchDetailDrawer: typeof import('./components/biz/BatchDetailDrawer.vue')['default']
     ChartBox: typeof import('./components/common/ChartBox.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
@@ -30,6 +31,7 @@ declare module 'vue' {
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElHeader: typeof import('element-plus/es')['ElHeader']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElMain: typeof import('element-plus/es')['ElMain']
@@ -38,6 +40,7 @@ declare module 'vue' {
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElRadio: typeof import('element-plus/es')['ElRadio']
+    ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
     ElResult: typeof import('element-plus/es')['ElResult']
     ElRow: typeof import('element-plus/es')['ElRow']
@@ -52,7 +55,11 @@ declare module 'vue' {
     ElTimeline: typeof import('element-plus/es')['ElTimeline']
     ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
     ElUpload: typeof import('element-plus/es')['ElUpload']
+    FlowAttachments: typeof import('./components/biz/FlowAttachments.vue')['default']
     PagePanel: typeof import('./components/common/PagePanel.vue')['default']
+    PersonnelDetailDescriptions: typeof import('./components/biz/PersonnelDetailDescriptions.vue')['default']
+    PersonnelFormFields: typeof import('./components/biz/PersonnelFormFields.vue')['default']
+    PersonStatusTimeline: typeof import('./components/biz/PersonStatusTimeline.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     StatCards: typeof import('./components/common/StatCards.vue')['default']

+ 86 - 0
src/components/biz/AlertLifecycleTimeline.vue

@@ -0,0 +1,86 @@
+<template>
+  <div v-loading="loading" class="alert-lifecycle">
+    <el-timeline v-if="items.length">
+      <el-timeline-item
+        v-for="(it, i) in items"
+        :key="i"
+        :timestamp="it.operateTime || ''"
+        placement="top"
+        :type="nodeType(it)"
+      >
+        <div class="alert-lifecycle__head">
+          <strong>{{ it.actionLabel || it.action || '办理' }}</strong>
+          <StatusTag v-if="it.status" :status="it.status" />
+        </div>
+        <div class="alert-lifecycle__meta">
+          {{ it.operator || '系统' }}
+          <template v-if="it.result"> · {{ it.result }}</template>
+        </div>
+        <div v-if="it.remark" class="alert-lifecycle__remark">备注:{{ it.remark }}</div>
+        <div v-if="it.fileUrls" class="alert-lifecycle__files">
+          <FlowAttachments :file-urls="it.fileUrls" />
+        </div>
+      </el-timeline-item>
+    </el-timeline>
+    <el-empty v-else-if="!loading" description="暂无处理流程" :image-size="56" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import StatusTag from '@/components/common/StatusTag.vue'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
+import type { AlertLifecycleItem } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    lifecycle?: AlertLifecycleItem[] | null
+    loading?: boolean
+  }>(),
+  {
+    lifecycle: () => [],
+    loading: false,
+  },
+)
+
+const items = computed(() => props.lifecycle || [])
+
+function nodeType(it: AlertLifecycleItem): 'success' | 'warning' | 'danger' | 'primary' | 'info' {
+  const s = String(it.status || it.actionLabel || '')
+  if (s.includes('关闭') || s.includes('完成')) return 'success'
+  if (s.includes('失败') || s.includes('驳回')) return 'danger'
+  if (s.includes('处理')) return 'warning'
+  return 'primary'
+}
+</script>
+
+<style scoped lang="less">
+.alert-lifecycle {
+  min-height: 80px;
+
+  &__head {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 4px;
+    font-size: 13px;
+  }
+
+  &__meta {
+    font-size: 12px;
+    color: @text-secondary;
+    line-height: 1.5;
+  }
+
+  &__remark {
+    margin-top: 6px;
+    font-size: 12px;
+    line-height: 1.5;
+    color: @text-primary;
+  }
+
+  &__files {
+    margin-top: 6px;
+  }
+}
+</style>

+ 245 - 11
src/components/biz/BatchDetailDrawer.vue

@@ -29,6 +29,9 @@
             <el-descriptions-item v-if="batch.supplementAmount != null" label="补发金额">
               {{ formatAmount(batch.supplementAmount) }}
             </el-descriptions-item>
+            <el-descriptions-item label="总发放金额(明细汇总)">
+              <strong>{{ formatAmount(peopleAmountSummary.payable) }}</strong>
+            </el-descriptions-item>
             <el-descriptions-item label="出盘状态">
               {{ batch.exportStatus || '未出盘' }}
             </el-descriptions-item>
@@ -74,7 +77,29 @@
             title="当前无可执行动作提示"
           />
 
-          <p class="batch-drawer__sub">本批次人员明细</p>
+          <p class="batch-drawer__sub">
+            本批次人员明细
+            <span class="batch-drawer__sub-amount">
+              共 {{ people.length }} 人,应发合计 {{ formatAmount(peopleAmountSummary.payable) }} 元
+              <template v-if="isAdjustBatch">
+                (调标差额批:应发=补发合计 {{ formatAmount(peopleAmountSummary.supplement) }})
+              </template>
+              <template v-else>
+                (月补 {{ formatAmount(peopleAmountSummary.monthly) }}
+                + 补发 {{ formatAmount(peopleAmountSummary.supplement) }}
+                + 丧葬 {{ formatAmount(peopleAmountSummary.funeral) }}
+                - 扣减 {{ formatAmount(peopleAmountSummary.deduct) }})
+              </template>
+            </span>
+          </p>
+          <el-alert
+            v-if="isAdjustBatch"
+            type="info"
+            :closable="false"
+            show-icon
+            class="batch-drawer__adjust-tip"
+            title="本批为年调标差额补发:不展示月标,本批只发调标差额,应发合计等于补发。"
+          />
           <el-table
             v-loading="peopleLoading"
             :data="pagedPeople"
@@ -82,20 +107,55 @@
             stripe
             size="small"
             max-height="280"
+            show-summary
+            :summary-method="peopleTableSummary"
           >
             <el-table-column prop="name" label="姓名" width="80" />
-            <el-table-column label="身份证" min-width="150">
+            <el-table-column label="身份证" min-width="140">
               <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
             </el-table-column>
-            <el-table-column label="标准(元/月)" width="100" align="right">
-              <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+            <el-table-column
+              v-if="!isAdjustBatch"
+              label="标准金额"
+              width="110"
+              align="right"
+            >
+              <template #default="{ row }">
+                {{ formatAmount(row.monthlyAmount ?? row.standard) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="补发金额" width="96" align="right">
+              <template #default="{ row }">{{ formatAmount(row.supplementAmount) }}</template>
+            </el-table-column>
+            <el-table-column v-if="!isAdjustBatch" label="丧葬金额" width="96" align="right">
+              <template #default="{ row }">{{ formatAmount(row.funeralAmount) }}</template>
             </el-table-column>
-            <el-table-column label="状态" width="110" align="center">
+            <el-table-column v-if="!isAdjustBatch" label="扣减" width="88" align="right">
+              <template #default="{ row }">{{ formatAmount(row.deductAmount) }}</template>
+            </el-table-column>
+            <el-table-column label="应发合计" width="104" align="right">
+              <template #default="{ row }">
+                <strong>{{ formatAmount(row.payableAmount) }}</strong>
+              </template>
+            </el-table-column>
+            <el-table-column label="状态" width="100" align="center">
               <template #default="{ row }">
                 <StatusTag :status="row.status" />
               </template>
             </el-table-column>
             <el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip />
+            <el-table-column label="操作" width="72" align="center" fixed="right">
+              <template #default="{ row }">
+                <el-button
+                  link
+                  type="primary"
+                  size="small"
+                  @click="openPersonPay(row as BatchMemberPerson)"
+                >
+                  详情
+                </el-button>
+              </template>
+            </el-table-column>
           </el-table>
           <TablePagination
             v-model:page="peoplePage"
@@ -130,6 +190,9 @@
                 </template>
               </div>
               <div v-if="log.opinion" class="batch-drawer__flow-opinion">{{ log.opinion }}</div>
+              <div v-if="log.fileUrls" class="batch-drawer__flow-files">
+                <FlowAttachments :file-urls="log.fileUrls" />
+              </div>
             </div>
           </el-timeline-item>
         </el-timeline>
@@ -143,6 +206,20 @@
             :rows="2"
             placeholder="可补充说明(将写入流程留痕)"
           />
+          <el-upload
+            v-model:file-list="noteFileList"
+            class="batch-drawer__note-upload"
+            :auto-upload="false"
+            :limit="5"
+            accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
+            multiple
+            @change="syncNoteFiles"
+            @remove="syncNoteFiles"
+          >
+            <el-button size="small" type="primary" class="batch-drawer__upload-btn">
+              选择附件
+            </el-button>
+          </el-upload>
           <el-button
             class="batch-drawer__note-btn"
             type="primary"
@@ -156,24 +233,95 @@
       </el-tab-pane>
     </el-tabs>
   </el-drawer>
+
+  <el-dialog
+    v-model="personPayVisible"
+    :title="personPayTitle"
+    width="520px"
+    append-to-body
+    destroy-on-close
+  >
+    <template v-if="currentPersonPay">
+      <el-descriptions :column="1" border size="small">
+        <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
+        <el-descriptions-item label="身份证">
+          {{ maskIdCard(currentPersonPay.idCard) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="街镇/村居">
+          {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
+        </el-descriptions-item>
+        <el-descriptions-item label="银行卡">
+          {{ maskBankCard(currentPersonPay.bankCard) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="!isAdjustBatch" label="月补标准(元/月)">
+          {{ formatAmount(currentPersonPay.monthlyAmount ?? currentPersonPay.standard) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="!isAdjustBatch" label="一次性代发">
+          {{ formatAmount(currentPersonPay.oneTimeAmount) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="补发金额">
+          {{ formatAmount(currentPersonPay.supplementAmount) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="!isAdjustBatch" label="丧葬费">
+          {{ formatAmount(currentPersonPay.funeralAmount) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="!isAdjustBatch" label="扣减金额">
+          {{ formatAmount(currentPersonPay.deductAmount) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="应发合计">
+          <strong>{{ formatAmount(currentPersonPay.payableAmount) }}</strong>
+          <div class="batch-drawer__pay-tip">
+            <template v-if="isAdjustBatch">
+              本批为年调标差额补发,不计入月标;应发合计=补发。
+            </template>
+            <template v-else>
+              应发=月补标准+一次性代发+补发+丧葬费−扣减(以后台重算为准)。
+            </template>
+          </div>
+        </el-descriptions-item>
+        <el-descriptions-item label="实发金额">
+          {{
+            currentPersonPay.actualAmount == null
+              ? '未回盘'
+              : formatAmount(currentPersonPay.actualAmount)
+          }}
+        </el-descriptions-item>
+        <el-descriptions-item label="回盘结果">
+          {{ currentPersonPay.returnResult || '—' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="备注">
+          {{ currentPersonPay.remark || '—' }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </template>
+  </el-dialog>
 </template>
 
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
+import type { UploadFile, UploadUserFile } from 'element-plus'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import {
   addBatchProcessNote,
   fetchBatchDetail,
   fetchBatchMemberPeople,
   fetchBatchOverview,
   fetchBatchTimeline,
+  uploadBizFile,
 } from '@/api/biz'
 import { actionLabel } from '@/constants/batchActions'
 import { useClientPagination } from '@/composables/useClientPagination'
-import { formatAmount, maskIdCard } from '@/utils/mask'
-import type { BatchFlowOverview, Person, ProcessLogItem, VillageBatch } from '@/types/biz'
+import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
+import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import type {
+  BatchFlowOverview,
+  BatchMemberPerson,
+  ProcessLogItem,
+  VillageBatch,
+} from '@/types/biz'
 
 const props = withDefaults(
   defineProps<{
@@ -197,9 +345,23 @@ const noteSaving = ref(false)
 const batch = ref<VillageBatch | null>(null)
 const overview = ref<BatchFlowOverview | null>(null)
 const logs = ref<ProcessLogItem[]>([])
-const people = ref<Person[]>([])
+const people = ref<BatchMemberPerson[]>([])
 const noteOpinion = ref('')
+const noteFileList = ref<UploadUserFile[]>([])
+const noteRawFiles = ref<File[]>([])
 const flowLoadedFor = ref('')
+const personPayVisible = ref(false)
+const currentPersonPay = ref<BatchMemberPerson | null>(null)
+const personPayTitle = computed(() =>
+  currentPersonPay.value
+    ? `${currentPersonPay.value.name} · 发放详情`
+    : '发放详情',
+)
+
+function openPersonPay(row: BatchMemberPerson) {
+  currentPersonPay.value = row
+  personPayVisible.value = true
+}
 
 const {
   page: peoplePage,
@@ -209,6 +371,31 @@ const {
   resetPage: resetPeoplePage,
 } = useClientPagination(people, { pageSize: 10 })
 
+const peopleAmountSummary = computed(() => sumBatchMemberAmounts(people.value))
+
+const isAdjustBatch = computed(() => {
+  const no = String(batch.value?.batchNo || '').replace(/-/g, '')
+  if (no.length >= 6 && no.endsWith('99')) return true
+  const remarks = String(batch.value?.remarks || '')
+  return remarks.includes('调标') || remarks.includes('YEARLY_ADJUST')
+})
+
+function peopleTableSummary(param: {
+  columns: Array<{ label?: string; property?: string }>
+}) {
+  const sum = peopleAmountSummary.value
+  return param.columns.map((col, index) => {
+    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)
+    if (label.includes('丧葬')) return formatAmount(sum.funeral)
+    if (label.includes('扣减')) return formatAmount(sum.deduct)
+    if (label.includes('应发') || label.includes('总计')) return formatAmount(sum.payable)
+    return ''
+  })
+}
+
 const drawerTitle = computed(() => {
   if (!batch.value) return '批次详情'
   return `批次 ${batch.value.batchNo || batch.value.id}`
@@ -289,20 +476,33 @@ async function load() {
   }
 }
 
+function syncNoteFiles(_file: UploadFile, fileList: UploadFile[]) {
+  noteFileList.value = fileList
+  noteRawFiles.value = fileList.map((f) => f.raw).filter((f) => !!f) as File[]
+}
+
 async function saveNote() {
   if (!props.batchId) return
-  if (!noteOpinion.value.trim()) {
-    ElMessage.warning('请填写备注内容')
+  if (!noteOpinion.value.trim() && !noteRawFiles.value.length) {
+    ElMessage.warning('请填写备注或上传附件')
     return
   }
   noteSaving.value = true
   try {
+    const uploaded: string[] = []
+    for (const f of noteRawFiles.value) {
+      const r = await uploadBizFile(f)
+      if (r?.url) uploaded.push(r.url)
+    }
     await addBatchProcessNote({
       batchId: props.batchId,
-      opinion: noteOpinion.value.trim(),
+      opinion: noteOpinion.value.trim() || '补充附件',
+      fileUrls: uploaded.length ? uploaded.join(',') : undefined,
     })
     ElMessage.success('备注已记录')
     noteOpinion.value = ''
+    noteFileList.value = []
+    noteRawFiles.value = []
     await reloadFlow(true)
   } finally {
     noteSaving.value = false
@@ -315,6 +515,8 @@ function onClosed() {
   logs.value = []
   people.value = []
   noteOpinion.value = ''
+  noteFileList.value = []
+  noteRawFiles.value = []
   flowLoadedFor.value = ''
 }
 
@@ -377,6 +579,30 @@ watch(tab, (name) => {
     color: @text-primary;
   }
 
+  &__sub-amount {
+    display: inline;
+    margin-left: 10px;
+    font-size: 12px;
+    font-weight: 400;
+    color: @primary-dark;
+  }
+
+  &__adjust-tip {
+    margin-bottom: 8px;
+  }
+
+  &__upload-btn {
+    color: #fff !important;
+  }
+
+  &__pay-tip {
+    margin-top: 4px;
+    font-size: 12px;
+    font-weight: 400;
+    color: @text-secondary;
+    line-height: 1.5;
+  }
+
   &__flow-item {
     line-height: 1.5;
   }
@@ -404,6 +630,10 @@ watch(tab, (name) => {
     font-size: 13px;
   }
 
+  &__flow-files {
+    margin-top: 6px;
+  }
+
   &__note {
     margin-top: 16px;
     padding: 12px;
@@ -412,6 +642,10 @@ watch(tab, (name) => {
     border-radius: @radius-sm;
   }
 
+  &__note-upload {
+    margin-top: 8px;
+  }
+
   &__note-btn {
     margin-top: 10px;
   }

+ 117 - 0
src/components/biz/FlowAttachments.vue

@@ -0,0 +1,117 @@
+<template>
+  <div v-if="links.length" class="flow-attachments">
+    <div v-if="imageLinks.length" class="flow-attachments__images">
+      <el-image
+        v-for="(f, i) in imageLinks"
+        :key="`img-${i}-${f.href}`"
+        :src="f.href"
+        :preview-src-list="imagePreviewList"
+        :initial-index="i"
+        fit="cover"
+        class="flow-attachments__thumb"
+        preview-teleported
+      >
+        <template #error>
+          <div class="flow-attachments__thumb-fallback">
+            <a :href="f.href" target="_blank" rel="noopener">{{ f.name }}</a>
+          </div>
+        </template>
+      </el-image>
+    </div>
+    <div v-if="otherLinks.length" class="flow-attachments__files">
+      <a
+        v-for="(f, i) in otherLinks"
+        :key="`file-${i}-${f.href}`"
+        :href="f.href"
+        target="_blank"
+        rel="noopener"
+        download
+      >
+        {{ f.name }}
+      </a>
+    </div>
+  </div>
+  <span v-else-if="showEmpty" class="flow-attachments__empty">未上传</span>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { parseBizFileLinks, type BizFileLink } from '@/utils/bizFiles'
+
+const props = withDefaults(
+  defineProps<{
+    /** 逗号分隔的附件 URL,或 FILES: 编码串 */
+    fileUrls?: string | null
+    /** 已解析的链接(优先于 fileUrls) */
+    links?: BizFileLink[]
+    showEmpty?: boolean
+  }>(),
+  {
+    fileUrls: '',
+    links: undefined,
+    showEmpty: false,
+  },
+)
+
+const links = computed(() =>
+  props.links?.length ? props.links : parseBizFileLinks(props.fileUrls),
+)
+
+const imageLinks = computed(() => links.value.filter((f) => f.isImage))
+const otherLinks = computed(() => links.value.filter((f) => !f.isImage))
+const imagePreviewList = computed(() => imageLinks.value.map((f) => f.href))
+</script>
+
+<style scoped lang="less">
+.flow-attachments {
+  &__images {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 4px;
+  }
+
+  &__thumb {
+    width: 72px;
+    height: 72px;
+    border-radius: 4px;
+    border: 1px solid rgba(0, 0, 0, 0.08);
+    cursor: pointer;
+    overflow: hidden;
+  }
+
+  &__thumb-fallback {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 4px;
+    font-size: 11px;
+    word-break: break-all;
+    background: #f5f5f5;
+  }
+
+  &__files {
+    margin-top: 4px;
+    font-size: 12px;
+    line-height: 1.7;
+
+    a {
+      display: inline-block;
+      margin-right: 12px;
+      color: var(--el-color-primary);
+      text-decoration: none;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  &__empty {
+    color: var(--el-text-color-secondary);
+    font-size: 12px;
+  }
+}
+</style>

+ 198 - 0
src/components/biz/PersonStatusTimeline.vue

@@ -0,0 +1,198 @@
+<template>
+  <div v-loading="loading" class="person-status-timeline">
+    <div v-if="showFilter" class="person-status-timeline__filter">
+      <el-radio-group v-model="filter" size="small">
+        <el-radio-button label="all">全部</el-radio-button>
+        <el-radio-button label="biz">业务状态</el-radio-button>
+        <el-radio-button label="insurance">居保匹配</el-radio-button>
+      </el-radio-group>
+    </div>
+
+    <el-timeline v-if="filtered.length">
+      <el-timeline-item
+        v-for="(log, i) in filtered"
+        :key="log.id || i"
+        :timestamp="log.operateTime || ''"
+        placement="top"
+        :type="nodeType(log)"
+      >
+        <div class="person-status-timeline__head">
+          <strong>{{ log.actionLabel || log.action || '办理' }}</strong>
+          <el-tag v-if="log.result" size="small" effect="plain">{{ log.result }}</el-tag>
+          <el-tag v-if="log.bizType" size="small" type="info" effect="plain">
+            {{ bizTypeLabel(log.bizType) }}
+          </el-tag>
+        </div>
+        <div class="person-status-timeline__meta">
+          操作人:{{ log.operator || log.operatorOrg || '系统' }}
+          <template v-if="log.operatorRole">({{ log.operatorRole }})</template>
+        </div>
+        <div v-if="statusHint(log)" class="person-status-timeline__status">
+          {{ statusHint(log) }}
+        </div>
+        <div v-if="log.opinion" class="person-status-timeline__remark">
+          意见/备注:{{ log.opinion }}
+        </div>
+        <div v-else-if="log.remarks" class="person-status-timeline__remark">
+          备注:{{ log.remarks }}
+        </div>
+        <div v-if="log.fileUrls" class="person-status-timeline__files">
+          <span>附件:</span>
+          <FlowAttachments :file-urls="log.fileUrls" />
+        </div>
+      </el-timeline-item>
+    </el-timeline>
+    <el-empty
+      v-else-if="!loading"
+      :description="emptyText"
+      :image-size="56"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
+import { labelOf, tryLabelOf } from '@/utils/statusLabels'
+import type { ProcessLogItem } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    logs?: ProcessLogItem[] | null
+    loading?: boolean
+    showFilter?: boolean
+    /** 默认筛选项 */
+    initialFilter?: 'all' | 'biz' | 'insurance'
+  }>(),
+  {
+    logs: () => [],
+    loading: false,
+    showFilter: true,
+    initialFilter: 'all',
+  },
+)
+
+type FilterKey = 'all' | 'biz' | 'insurance'
+const filter = ref<FilterKey>(props.initialFilter || 'all')
+
+watch(
+  () => props.initialFilter,
+  (v) => {
+    if (v) filter.value = v
+  },
+)
+
+const emptyText = computed(() => {
+  if (filter.value === 'biz') return '暂无业务状态变更记录'
+  if (filter.value === 'insurance') return '暂无居保匹配变更记录'
+  return '暂无状态变更日志'
+})
+
+const filtered = computed(() => {
+  const list = props.logs || []
+  if (filter.value === 'all') return list
+  if (filter.value === 'insurance') return list.filter(isInsuranceLog)
+  return list.filter(isBizStatusLog)
+})
+
+function textOf(log: ProcessLogItem) {
+  return [
+    log.action,
+    log.actionLabel,
+    log.bizType,
+    log.status,
+    log.opinion,
+    log.remarks,
+    log.result,
+  ]
+    .filter(Boolean)
+    .join('|')
+}
+
+function isInsuranceLog(log: ProcessLogItem) {
+  return /居保|INSURANCE|MATCH|匹配|比对|MANUAL_PASS|LOSE_ELIGIBILITY/i.test(textOf(log))
+}
+
+function isBizStatusLog(log: ProcessLogItem) {
+  return /暂停|停止|恢复|STATUS_|PAUSE|RESUME|STOPPED|NORMAL|死亡|丧葬|归档|DRAFT|业务状态|发放/i.test(
+    textOf(log),
+  )
+}
+
+function bizTypeLabel(code?: string) {
+  if (!code) return ''
+  const hit =
+    tryLabelOf('specialBizType', code) ||
+    tryLabelOf('insuranceMatch', code) ||
+    tryLabelOf('personnelStatus', code)
+  return hit || labelOf('specialBizType', code)
+}
+
+function statusDisplay(code?: string) {
+  const s = String(code || '').trim()
+  if (!s) return ''
+  return (
+    tryLabelOf('insuranceMatch', s) ||
+    tryLabelOf('personnelStatus', s) ||
+    tryLabelOf('batchStatus', s) ||
+    tryLabelOf('alertHandleStatus', s) ||
+    s
+  )
+}
+
+function statusHint(log: ProcessLogItem) {
+  const s = String(log.status || '').trim()
+  if (!s) return ''
+  const zh = statusDisplay(s)
+  if (zh === log.result || zh === log.actionLabel || s === log.result) return ''
+  return `相关状态:${zh}`
+}
+
+function nodeType(log: ProcessLogItem): 'success' | 'warning' | 'danger' | 'primary' | 'info' {
+  const t = `${log.result || ''}${log.actionLabel || ''}${log.action || ''}`
+  if (/驳回|失败|取消/.test(t)) return 'danger'
+  if (/通过|成功|恢复|正常|完成/.test(t)) return 'success'
+  if (/暂停|停止|死亡|待/.test(t)) return 'warning'
+  return 'primary'
+}
+</script>
+
+<style scoped lang="less">
+.person-status-timeline {
+  min-height: 72px;
+
+  &__filter {
+    margin-bottom: 10px;
+  }
+
+  &__head {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 4px;
+    font-size: 13px;
+  }
+
+  &__meta,
+  &__status {
+    font-size: 12px;
+    color: @text-secondary;
+    line-height: 1.5;
+  }
+
+  &__remark {
+    margin-top: 6px;
+    font-size: 12px;
+    line-height: 1.5;
+    color: @text-primary;
+    word-break: break-all;
+  }
+
+  &__files {
+    margin-top: 6px;
+    font-size: 12px;
+    color: @text-secondary;
+  }
+}
+</style>

+ 298 - 0
src/components/biz/PersonnelDetailDescriptions.vue

@@ -0,0 +1,298 @@
+<template>
+  <div class="personnel-detail-descriptions">
+    <template v-for="g in groups" :key="g.key">
+      <el-divider content-position="left">
+        <span class="personnel-detail-descriptions__divider">
+          <span>{{ g.title }}</span>
+          <el-button
+            v-if="g.key === 'status' && showStatusLog"
+            link
+            type="primary"
+            size="small"
+            @click="toggleStatusLog()"
+          >
+            {{ statusLogOpen ? '收起变更日志' : '查看变更日志' }}
+          </el-button>
+        </span>
+      </el-divider>
+      <el-descriptions :column="2" border size="small" class="personnel-detail-descriptions__block">
+        <el-descriptions-item
+          v-for="f in fieldsOf(g.key)"
+          :key="f.key"
+          :label="f.label"
+          :span="f.span === 2 ? 2 : 1"
+        >
+          <template v-if="isStatusField(f.key)">
+            <span class="personnel-detail-descriptions__status-cell">
+              <StatusTag
+                v-if="f.key === 'status' || f.key === 'insuranceStatus'"
+                :status="displayText(f)"
+              />
+              <span v-else>{{ displayText(f) }}</span>
+              <el-button
+                v-if="showStatusLog"
+                class="personnel-detail-descriptions__log-btn"
+                link
+                type="primary"
+                size="small"
+                @click="openStatusLog(f.key)"
+              >
+                日志
+              </el-button>
+            </span>
+          </template>
+          <template v-else-if="f.key === 'payThisMonth'">
+            {{ person.payThisMonth == null ? '—' : person.payThisMonth ? '是' : '否' }}
+          </template>
+          <template v-else-if="isAmount(f.key)">
+            {{ formatAmount(numVal(f.key)) }}
+          </template>
+          <template v-else>
+            {{ displayText(f) }}
+          </template>
+        </el-descriptions-item>
+      </el-descriptions>
+
+      <div
+        v-if="g.key === 'status' && showStatusLog && statusLogOpen"
+        class="personnel-detail-descriptions__log-panel"
+      >
+        <p class="personnel-detail-descriptions__log-tip">
+          含业务状态(正常/暂停/停止等)与居保匹配变更;展示操作人、时间、意见、附件。
+        </p>
+        <PersonStatusTimeline
+          :logs="statusLogs"
+          :loading="statusLogLoading"
+          :initial-filter="logFilter"
+        />
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import StatusTag from '@/components/common/StatusTag.vue'
+import PersonStatusTimeline from '@/components/biz/PersonStatusTimeline.vue'
+import { fetchPersonnelTimeline } from '@/api/biz'
+import {
+  PERSONNEL_FIELD_GROUPS,
+  visiblePersonnelFields,
+  type PersonnelFieldDef,
+  type PersonnelFieldGroup,
+} from '@/constants/personnelFields'
+import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { labelOf, personnelStatusLabel } from '@/utils/statusLabels'
+import type { Person, ProcessLogItem } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    person: Person
+    /** 是否对证件号/银行卡脱敏,默认 true */
+    maskSensitive?: boolean
+    /** 是否展示状态变更日志入口,默认 true */
+    showStatusLog?: boolean
+    /** 打开详情时是否默认展开日志 */
+    statusLogDefaultOpen?: boolean
+  }>(),
+  {
+    maskSensitive: true,
+    showStatusLog: true,
+    statusLogDefaultOpen: false,
+  },
+)
+
+type LogFilter = 'all' | 'biz' | 'insurance'
+
+const STATUS_LOG_KEYS = new Set(['insuranceStatus', 'status'])
+
+const fields = computed(() =>
+  visiblePersonnelFields().filter((f) => !f.detailHidden),
+)
+const groups = computed(() =>
+  PERSONNEL_FIELD_GROUPS.filter((g) => fields.value.some((f) => f.group === g.key)),
+)
+
+const statusLogOpen = ref(false)
+const statusLogLoading = ref(false)
+const statusLogs = ref<ProcessLogItem[]>([])
+const loadedForId = ref('')
+const logFilter = ref<LogFilter>('all')
+
+function fieldsOf(group: PersonnelFieldGroup) {
+  return fields.value.filter((f) => f.group === group)
+}
+
+function isStatusField(key: string) {
+  return STATUS_LOG_KEYS.has(key)
+}
+
+function isAmount(key: string) {
+  return (
+    key === 'standard' ||
+    key === 'baseAmount' ||
+    key === 'oneTimeAmount' ||
+    key === 'funeralDiffAmount' ||
+    key === 'landSubsidyIncrease'
+  )
+}
+
+function personField(key: string): unknown {
+  return (props.person as unknown as Record<string, unknown>)[key]
+}
+
+function numVal(key: string): number | undefined {
+  const v = personField(key)
+  if (v == null || v === '') return undefined
+  const n = Number(v)
+  return Number.isFinite(n) ? n : undefined
+}
+
+function displayText(f: PersonnelFieldDef): string {
+  const raw = personField(f.key)
+  if (raw == null || raw === '') return '—'
+  const s = String(raw)
+  if (f.key === 'bizStatus') return personnelStatusLabel(s)
+  if (f.key === 'insuranceMatchResult') return labelOf('insuranceMatch', s)
+  if (props.maskSensitive && f.mask === 'idCard') return maskIdCard(s)
+  if (props.maskSensitive && f.mask === 'bankCard') return maskBankCard(s)
+  return s
+}
+
+function isInsuranceLog(log: ProcessLogItem) {
+  const t = [
+    log.action,
+    log.actionLabel,
+    log.bizType,
+    log.status,
+    log.opinion,
+    log.remarks,
+  ]
+    .filter(Boolean)
+    .join('|')
+  return /居保|INSURANCE|MATCH|匹配|比对|MANUAL_PASS|LOSE_ELIGIBILITY/i.test(t)
+}
+
+/** 历史数据未写流程留痕时,用人员当前居保结果补一条可读记录 */
+function buildInsuranceSnapshot(person: Person): ProcessLogItem | null {
+  const code = String(person.insuranceMatchResult || '').trim()
+  const label = String(person.insuranceStatus || '').trim()
+  if (!code && (!label || label === '—' || label === '未匹配')) return null
+  const resultZh = code ? labelOf('insuranceMatch', code) : label
+  return {
+    id: `ins-snap-${person.id}`,
+    action: 'INSURANCE_MATCH_SNAPSHOT',
+    actionLabel: '居保匹配结果(当前)',
+    bizType: 'INSURANCE_MATCH',
+    operator: '系统',
+    operatorRole: '居保匹配',
+    result: resultZh || '已匹配',
+    status: code || label,
+    opinion: `当前居保养老状态:${label || resultZh || '—'};匹配结果:${resultZh || '—'}`,
+    operateTime: person.updateTime || person.createTime,
+    remarks: 'HIST_SNAPSHOT|INSURANCE_MATCH',
+  }
+}
+
+function mergeLogs(list: ProcessLogItem[], person: Person): ProcessLogItem[] {
+  const hasIns = list.some(isInsuranceLog)
+  if (hasIns) return list
+  const snap = buildInsuranceSnapshot(person)
+  return snap ? [...list, snap] : list
+}
+
+async function loadStatusLogs(force = false) {
+  const pid = String(props.person?.id || '').trim()
+  if (!pid) {
+    statusLogs.value = []
+    loadedForId.value = ''
+    return
+  }
+  if (!force && loadedForId.value === pid) return
+  statusLogLoading.value = true
+  try {
+    const list = await fetchPersonnelTimeline(pid)
+    statusLogs.value = mergeLogs(list, props.person)
+    loadedForId.value = pid
+  } finally {
+    statusLogLoading.value = false
+  }
+}
+
+function openStatusLog(fieldKey?: string) {
+  if (fieldKey === 'insuranceStatus' || fieldKey === 'insuranceMatchResult') {
+    logFilter.value = 'insurance'
+  } else if (fieldKey === 'status' || fieldKey === 'bizStatus') {
+    logFilter.value = 'biz'
+  } else {
+    logFilter.value = 'all'
+  }
+  statusLogOpen.value = true
+  void loadStatusLogs()
+}
+
+function toggleStatusLog() {
+  if (statusLogOpen.value) {
+    statusLogOpen.value = false
+    return
+  }
+  logFilter.value = 'all'
+  statusLogOpen.value = true
+  void loadStatusLogs()
+}
+
+watch(
+  () => String(props.person?.id || ''),
+  (id, prev) => {
+    if (id !== prev) {
+      statusLogs.value = []
+      loadedForId.value = ''
+      logFilter.value = 'all'
+      statusLogOpen.value = Boolean(props.statusLogDefaultOpen && props.showStatusLog)
+      if (statusLogOpen.value) void loadStatusLogs(true)
+    }
+  },
+  { immediate: true },
+)
+</script>
+
+<style scoped lang="less">
+.personnel-detail-descriptions__block {
+  margin-bottom: 4px;
+}
+.personnel-detail-descriptions :deep(.el-divider) {
+  margin: 12px 0 10px;
+}
+.personnel-detail-descriptions :deep(.el-divider:first-child) {
+  margin-top: 0;
+}
+.personnel-detail-descriptions__divider {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+}
+.personnel-detail-descriptions__status-cell {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+.personnel-detail-descriptions__log-btn {
+  padding: 0 2px;
+  height: auto;
+  font-size: 12px;
+}
+.personnel-detail-descriptions__log-panel {
+  margin: 4px 0 12px;
+  padding: 10px 12px 4px;
+  background: @page-bg-alt;
+  border: 1px solid @border-color;
+  border-radius: 4px;
+}
+.personnel-detail-descriptions__log-tip {
+  margin: 0 0 8px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
+</style>

+ 215 - 0
src/components/biz/PersonnelFormFields.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="personnel-form-fields">
+    <template v-for="g in groups" :key="g.key">
+      <el-divider content-position="left">{{ g.title }}</el-divider>
+      <el-row :gutter="12">
+        <el-col
+          v-for="f in fieldsOf(g.key)"
+          :key="f.key"
+          :span="f.span === 2 ? 24 : 12"
+        >
+          <el-form-item :label="f.label" :prop="formProp(f.key)" :required="isRequired(f.key)">
+            <!-- 街镇 -->
+            <el-select
+              v-if="f.control === 'town'"
+              :model-value="model.town"
+              :disabled="!canEdit(f) || townDisabled"
+              filterable
+              clearable
+              style="width: 100%"
+              placeholder="请选择街镇"
+              @update:model-value="onTownUpdate"
+            >
+              <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+            <!-- 村居 -->
+            <el-select
+              v-else-if="f.control === 'village'"
+              :model-value="model.village"
+              :disabled="!canEdit(f) || villageDisabled"
+              filterable
+              clearable
+              style="width: 100%"
+              placeholder="请选择村居"
+              @update:model-value="setField('village', $event)"
+            >
+              <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
+            </el-select>
+            <el-date-picker
+              v-else-if="f.control === 'month'"
+              :model-value="strVal(f.key)"
+              type="month"
+              value-format="YYYY-MM"
+              :disabled="!canEdit(f)"
+              style="width: 100%"
+              placeholder="选填"
+              @update:model-value="setField(f.key, $event || '')"
+            />
+            <el-date-picker
+              v-else-if="f.control === 'date'"
+              :model-value="strVal(f.key)"
+              type="date"
+              value-format="YYYY-MM-DD"
+              :disabled="!canEdit(f)"
+              style="width: 100%"
+              placeholder="选填"
+              @update:model-value="setField(f.key, $event || '')"
+            />
+            <el-input-number
+              v-else-if="f.control === 'number'"
+              :model-value="numVal(f.key)"
+              :disabled="!canEdit(f)"
+              :precision="2"
+              :controls="false"
+              style="width: 100%"
+              placeholder="选填"
+              @update:model-value="setField(f.key, $event)"
+            />
+            <el-select
+              v-else-if="f.control === 'select'"
+              :model-value="strVal(f.key)"
+              :disabled="!canEdit(f)"
+              clearable
+              style="width: 100%"
+              placeholder="选填"
+              @update:model-value="setField(f.key, $event || '')"
+            >
+              <el-option
+                v-for="opt in f.options || []"
+                :key="opt.value"
+                :label="opt.label"
+                :value="opt.value"
+              />
+            </el-select>
+            <el-switch
+              v-else-if="f.control === 'switch'"
+              :model-value="!!model.payThisMonth"
+              :disabled="!canEdit(f)"
+              @update:model-value="setField('payThisMonth', $event)"
+            />
+            <el-input
+              v-else-if="f.control === 'textarea'"
+              :model-value="strVal(f.key)"
+              type="textarea"
+              :rows="2"
+              :disabled="!canEdit(f)"
+              placeholder="选填"
+              @update:model-value="setField(f.key, $event)"
+            />
+            <el-input
+              v-else
+              :model-value="displayReadonly(f)"
+              :disabled="!canEdit(f)"
+              :placeholder="canEdit(f) ? '选填' : '—'"
+              @update:model-value="setField(f.key, $event)"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import {
+  PERSONNEL_FIELD_GROUPS,
+  visiblePersonnelFields,
+  type PersonnelFieldDef,
+  type PersonnelFieldGroup,
+  type PersonnelFieldKey,
+} from '@/constants/personnelFields'
+import type { Person } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    model: Person
+    /** create:新增;edit:修改 */
+    mode?: 'create' | 'edit'
+    townOptions?: string[]
+    villageOptions?: string[]
+    townDisabled?: boolean
+    villageDisabled?: boolean
+    /** 必填字段(默认姓名/身份证/银行卡/街镇/村居) */
+    requiredKeys?: PersonnelFieldKey[]
+  }>(),
+  {
+    mode: 'create',
+    townOptions: () => [],
+    villageOptions: () => [],
+    townDisabled: false,
+    villageDisabled: false,
+    requiredKeys: () => ['name', 'idCard', 'bankCard', 'town', 'village'],
+  },
+)
+
+const emit = defineEmits<{
+  'update:model': [Person]
+  townChange: [string]
+}>()
+
+const fields = computed(() => visiblePersonnelFields())
+const groups = computed(() =>
+  PERSONNEL_FIELD_GROUPS.filter((g) => fields.value.some((f) => f.group === g.key)),
+)
+
+function fieldsOf(group: PersonnelFieldGroup) {
+  return fields.value.filter((f) => f.group === group)
+}
+
+function canEdit(f: PersonnelFieldDef) {
+  return f.editable !== false
+}
+
+function isRequired(key: PersonnelFieldKey) {
+  return props.requiredKeys.includes(key) && canEdit(
+    fields.value.find((x) => x.key === key) || { key, label: '', group: 'basic', editable: true },
+  )
+}
+
+/** el-form rules 用 name;告警页若仍用 personName 可外层适配 */
+function formProp(key: PersonnelFieldKey) {
+  return key
+}
+
+function strVal(key: PersonnelFieldKey): string {
+  const v = (props.model as Record<string, unknown>)[key]
+  return v == null ? '' : String(v)
+}
+
+function numVal(key: PersonnelFieldKey): number | undefined {
+  const v = (props.model as Record<string, unknown>)[key]
+  if (v == null || v === '') return undefined
+  const n = Number(v)
+  return Number.isFinite(n) ? n : undefined
+}
+
+function displayReadonly(f: PersonnelFieldDef): string {
+  if (f.key === 'payThisMonth') {
+    if (props.model.payThisMonth == null) return ''
+    return props.model.payThisMonth ? '是' : '否'
+  }
+  return strVal(f.key)
+}
+
+function setField(key: PersonnelFieldKey | string, value: unknown) {
+  emit('update:model', { ...props.model, [key]: value })
+}
+
+function onTownUpdate(v: string) {
+  emit('update:model', { ...props.model, town: v || '', village: '' })
+  emit('townChange', v || '')
+}
+</script>
+
+<style scoped>
+.personnel-form-fields :deep(.el-divider) {
+  margin: 10px 0 12px;
+}
+.personnel-form-fields :deep(.el-divider:first-child) {
+  margin-top: 0;
+}
+.personnel-form-fields :deep(.el-form-item) {
+  margin-bottom: 12px;
+}
+</style>

+ 18 - 2
src/components/common/StatusTag.vue

@@ -1,10 +1,11 @@
 <template>
-  <el-tag :type="type" size="small" effect="plain">{{ status }}</el-tag>
+  <el-tag :type="type" size="small" effect="plain">{{ display }}</el-tag>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue'
 import { levelTagType, statusTagType } from '@/utils/statusTag'
+import { labelOf, tryLabelOf } from '@/utils/statusLabels'
 
 const props = withDefaults(
   defineProps<{
@@ -15,7 +16,22 @@ const props = withDefaults(
   { mode: 'status' },
 )
 
+const display = computed(() => {
+  const s = String(props.status || '')
+  if (!s) return ''
+  if (props.mode === 'level') return labelOf('alertLevel', s)
+  return (
+    tryLabelOf('batchStatus', s) ||
+    tryLabelOf('flowStage', s) ||
+    tryLabelOf('personnelStatus', s) ||
+    tryLabelOf('alertHandleStatus', s) ||
+    tryLabelOf('insuranceMatch', s) ||
+    tryLabelOf('specialBizType', s) ||
+    s
+  )
+})
+
 const type = computed(() =>
-  props.mode === 'level' ? levelTagType(props.status) : statusTagType(props.status),
+  props.mode === 'level' ? levelTagType(props.status) : statusTagType(display.value || props.status),
 )
 </script>

+ 10 - 1
src/composables/useClientPagination.ts

@@ -10,6 +10,15 @@ export const PAGE_SIZES = [10, 20, 50]
  * 前端列表分页(对接真实接口前先对本地数组切片)
  * @param source 完整列表 ref,或返回列表的 getter(适合 computed)
  */
+function asArray<T>(value: unknown): T[] {
+  if (Array.isArray(value)) return value
+  if (value && typeof value === 'object') {
+    const list = (value as { list?: unknown }).list
+    if (Array.isArray(list)) return list as T[]
+  }
+  return []
+}
+
 export function useClientPagination<T>(
   source: Ref<T[]> | (() => T[]),
   options?: { pageSize?: number },
@@ -18,7 +27,7 @@ export function useClientPagination<T>(
   const pageSize = ref(options?.pageSize ?? DEFAULT_PAGE_SIZE)
 
   const sourceList = computed(() =>
-    typeof source === 'function' ? source() : source.value,
+    asArray<T>(typeof source === 'function' ? source() : source.value),
   )
 
   const total = computed(() => sourceList.value.length)

+ 60 - 0
src/composables/useServerPagination.ts

@@ -0,0 +1,60 @@
+import { ref, watch, type Ref } from 'vue'
+
+export type ServerPageFetcher<T> = (pageNum: number, pageSize: number) => Promise<{
+  list: T[]
+  total: number
+}>
+
+/**
+ * 服务端分页:翻页/改 pageSize 时重新请求(不自动 immediate,由页面 resetAndLoad 触发)。
+ */
+export function useServerPagination<T>(
+  fetcher: ServerPageFetcher<T>,
+  options?: { pageSize?: number },
+) {
+  const page = ref(1)
+  const pageSize = ref(options?.pageSize ?? 10)
+  const total = ref(0)
+  const list = ref<T[]>([]) as Ref<T[]>
+  const loading = ref(false)
+  let skipWatch = false
+
+  async function load() {
+    loading.value = true
+    try {
+      const res = await fetcher(page.value, pageSize.value)
+      list.value = res.list || []
+      total.value = Number(res.total || 0)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function resetAndLoad() {
+    skipWatch = true
+    page.value = 1
+    skipWatch = false
+    await load()
+  }
+
+  watch(page, () => {
+    if (skipWatch) return
+    void load()
+  })
+  watch(pageSize, () => {
+    skipWatch = true
+    page.value = 1
+    skipWatch = false
+    void load()
+  })
+
+  return {
+    page,
+    pageSize,
+    total,
+    list,
+    loading,
+    load,
+    resetAndLoad,
+  }
+}

+ 251 - 0
src/constants/personnelFields.ts

@@ -0,0 +1,251 @@
+/**
+ * 人员表(sjnmtybt_ryjbxx / PersonnelVO)字段目录。
+ * 新增/修改/详情统一按此清单展示;不需要显示的 key 加入 HIDDEN_PERSONNEL_FIELD_KEYS。
+ */
+import type { Person } from '@/types/biz'
+
+export type PersonnelFieldKey =
+  | 'id'
+  | 'name'
+  | 'idCard'
+  | 'socialInsuranceCode'
+  | 'phoneNumber'
+  | 'address'
+  | 'householdType'
+  | 'districtId'
+  | 'town'
+  | 'village'
+  | 'townId'
+  | 'villageId'
+  | 'villageCode'
+  | 'entrustPlanCode'
+  | 'entrustPlanName'
+  | 'payeeName'
+  | 'bankCard'
+  | 'bankName'
+  | 'bankType'
+  | 'bankBranchCode'
+  | 'startMonth'
+  | 'enjoyEndMonth'
+  | 'supplementStartMonth'
+  | 'nextPayStartMonth'
+  | 'standard'
+  | 'baseAmount'
+  | 'oneTimeAmount'
+  | 'insuranceStatus'
+  | 'insuranceMatchResult'
+  | 'bankCardConfirmStatus'
+  | 'status'
+  | 'bizStatus'
+  | 'payThisMonth'
+  | 'source'
+  | 'inputVillageId'
+  | 'deathDate'
+  | 'heirName'
+  | 'heirRelation'
+  | 'heirIdNumber'
+  | 'heirBankCard'
+  | 'heirPhone'
+  | 'funeralDiffAmount'
+  | 'landSubsidyIncrease'
+  | 'retireQualifyType'
+  | 'remark'
+  | 'createTime'
+  | 'updateTime'
+
+export type PersonnelFieldGroup =
+  | 'basic'
+  | 'region'
+  | 'bank'
+  | 'pay'
+  | 'status'
+  | 'death'
+  | 'other'
+
+export interface PersonnelFieldDef {
+  key: PersonnelFieldKey
+  label: string
+  group: PersonnelFieldGroup
+  /** 表单是否可编辑(详情一律展示) */
+  editable?: boolean
+  /** 人员详情是否隐藏(避免与展示字段重复) */
+  detailHidden?: boolean
+  /** 表单控件 */
+  control?: 'text' | 'textarea' | 'month' | 'date' | 'number' | 'select' | 'switch' | 'town' | 'village'
+  options?: Array<{ label: string; value: string }>
+  span?: 1 | 2
+  /** 详情脱敏:idCard / bankCard */
+  mask?: 'idCard' | 'bankCard'
+}
+
+export const PERSONNEL_FIELD_GROUPS: Array<{ key: PersonnelFieldGroup; title: string }> = [
+  { key: 'basic', title: '基本信息' },
+  { key: 'region', title: '区划与委托' },
+  { key: 'bank', title: '银行信息' },
+  { key: 'pay', title: '发放与标准' },
+  { key: 'status', title: '居保与业务状态' },
+  { key: 'death', title: '死亡与继承人' },
+  { key: 'other', title: '其他' },
+]
+
+/** 暂无可隐藏;你确认后把不需要的 key 加进来即可 */
+export const HIDDEN_PERSONNEL_FIELD_KEYS = new Set<PersonnelFieldKey>([
+  // 例:'districtId', 'townId', 'villageId',
+])
+
+export const PERSONNEL_FIELDS: PersonnelFieldDef[] = [
+  { key: 'id', label: '人员ID', group: 'basic', editable: false },
+  { key: 'name', label: '姓名', group: 'basic', editable: true, control: 'text' },
+  { key: 'idCard', label: '公民身份证号码', group: 'basic', editable: true, control: 'text', mask: 'idCard' },
+  {
+    key: 'socialInsuranceCode',
+    label: '个人社会保险登记码',
+    group: 'basic',
+    editable: true,
+    control: 'text',
+  },
+  { key: 'phoneNumber', label: '手机号', group: 'basic', editable: true, control: 'text' },
+  { key: 'address', label: '家庭住址/联系地址', group: 'basic', editable: true, control: 'textarea', span: 2 },
+  {
+    key: 'householdType',
+    label: '户籍性质',
+    group: 'basic',
+    editable: true,
+    control: 'select',
+    options: [
+      { label: '农业', value: '农业' },
+      { label: '非农', value: '非农' },
+    ],
+  },
+  { key: 'districtId', label: '区ID', group: 'region', editable: false },
+  { key: 'town', label: '街镇', group: 'region', editable: true, control: 'town' },
+  { key: 'village', label: '村居', group: 'region', editable: true, control: 'village' },
+  { key: 'townId', label: '街镇ID', group: 'region', editable: false },
+  { key: 'villageId', label: '村居ID', group: 'region', editable: false },
+  { key: 'villageCode', label: '村委代码', group: 'region', editable: true, control: 'text' },
+  { key: 'entrustPlanCode', label: '委托方案代码', group: 'region', editable: true, control: 'text' },
+  { key: 'entrustPlanName', label: '委托方案名称', group: 'region', editable: true, control: 'text' },
+  { key: 'inputVillageId', label: '录入村居ID', group: 'region', editable: false },
+  { key: 'payeeName', label: '收款人名称', group: 'bank', editable: true, control: 'text' },
+  { key: 'bankCard', label: '银行卡号', group: 'bank', editable: true, control: 'text', mask: 'bankCard' },
+  { key: 'bankName', label: '开户行', group: 'bank', editable: true, control: 'text' },
+  {
+    key: 'bankType',
+    label: '行别',
+    group: 'bank',
+    editable: true,
+    control: 'select',
+    options: [
+      { label: '农行', value: '农行' },
+      { label: '农商', value: '农商' },
+      { label: '工行', value: '工行' },
+      { label: '建行', value: '建行' },
+      { label: '其他', value: '其他' },
+    ],
+  },
+  { key: 'bankBranchCode', label: '收款银行行号', group: 'bank', editable: true, control: 'text' },
+  { key: 'startMonth', label: '享受/代发起始年月', group: 'pay', editable: true, control: 'month' },
+  { key: 'enjoyEndMonth', label: '代发截止年月', group: 'pay', editable: true, control: 'month' },
+  { key: 'supplementStartMonth', label: '补发起始年月', group: 'pay', editable: true, control: 'month' },
+  { key: 'nextPayStartMonth', label: '下次发放起始年月', group: 'pay', editable: true, control: 'month' },
+  { key: 'standard', label: '当前月度补助标准(元)', group: 'pay', editable: false, control: 'number' },
+  { key: 'baseAmount', label: '代发基数', group: 'pay', editable: true, control: 'number' },
+  { key: 'oneTimeAmount', label: '一次性代发金额', group: 'pay', editable: true, control: 'number' },
+  { key: 'landSubsidyIncrease', label: '土地退养增资额', group: 'pay', editable: true, control: 'number' },
+  { key: 'funeralDiffAmount', label: '丧葬费补差额', group: 'pay', editable: true, control: 'number' },
+  { key: 'insuranceStatus', label: '居保养老状态', group: 'status', editable: false },
+  { key: 'insuranceMatchResult', label: '居保匹配结果', group: 'status', editable: false, detailHidden: true },
+  { key: 'bankCardConfirmStatus', label: '银行卡确认状态', group: 'status', editable: false },
+  { key: 'status', label: '人员业务状态', group: 'status', editable: false },
+  { key: 'bizStatus', label: '人员业务状态', group: 'status', editable: false, detailHidden: true },
+  { key: 'payThisMonth', label: '是否本月发放', group: 'status', editable: false, control: 'switch' },
+  { key: 'retireQualifyType', label: '退养资格类型', group: 'status', editable: true, control: 'select', options: [
+    { label: '有土地退养', value: '有土地退养' },
+    { label: '无土地有资格', value: '无土地有资格' },
+    { label: '无资格', value: '无资格' },
+  ] },
+  { key: 'deathDate', label: '死亡日期', group: 'death', editable: true, control: 'date' },
+  { key: 'heirName', label: '继承人姓名', group: 'death', editable: true, control: 'text' },
+  {
+    key: 'heirRelation',
+    label: '与逝者关系',
+    group: 'death',
+    editable: true,
+    control: 'select',
+    options: [
+      { label: '配偶', value: '配偶' },
+      { label: '子女', value: '子女' },
+      { label: '父母', value: '父母' },
+      { label: '兄弟姐妹', value: '兄弟姐妹' },
+      { label: '其他', value: '其他' },
+    ],
+  },
+  { key: 'heirIdNumber', label: '继承人身份证', group: 'death', editable: true, control: 'text', mask: 'idCard' },
+  { key: 'heirBankCard', label: '继承人银行卡', group: 'death', editable: true, control: 'text', mask: 'bankCard' },
+  { key: 'heirPhone', label: '继承人电话', group: 'death', editable: true, control: 'text' },
+  { key: 'source', label: '录入来源', group: 'other', editable: true, control: 'text' },
+  { key: 'remark', label: '备注', group: 'other', editable: true, control: 'textarea', span: 2 },
+  { key: 'createTime', label: '创建时间', group: 'other', editable: false },
+  { key: 'updateTime', label: '更新时间', group: 'other', editable: false },
+]
+
+export function visiblePersonnelFields(): PersonnelFieldDef[] {
+  return PERSONNEL_FIELDS.filter((f) => !HIDDEN_PERSONNEL_FIELD_KEYS.has(f.key))
+}
+
+export function emptyPersonForm(partial?: Partial<Person>): Person {
+  return {
+    id: '',
+    batchNo: '',
+    name: '',
+    idCard: '',
+    town: '',
+    village: '',
+    bankCard: '',
+    bankName: '',
+    socialInsuranceCode: '',
+    standard: undefined,
+    startMonth: '',
+    status: '',
+    source: '',
+    insuranceStatus: '',
+    remark: '',
+    phoneNumber: '',
+    address: '',
+    householdType: '',
+    districtId: '',
+    townId: '',
+    villageId: '',
+    villageCode: '',
+    entrustPlanCode: '',
+    entrustPlanName: '',
+    payeeName: '',
+    bankType: '',
+    bankBranchCode: '',
+    enjoyEndMonth: '',
+    supplementStartMonth: '',
+    nextPayStartMonth: '',
+    baseAmount: undefined,
+    oneTimeAmount: undefined,
+    insuranceMatchResult: '',
+    bankCardConfirmStatus: '',
+    bizStatus: '',
+    payThisMonth: undefined,
+    inputVillageId: '',
+    deathDate: '',
+    heirName: '',
+    heirRelation: '',
+    heirIdNumber: '',
+    heirBankCard: '',
+    heirPhone: '',
+    funeralDiffAmount: undefined,
+    landSubsidyIncrease: undefined,
+    createTime: '',
+    updateTime: '',
+    ...partial,
+  }
+}
+
+export function personToForm(p: Person): Person {
+  return emptyPersonForm({ ...p })
+}

+ 123 - 6
src/types/biz.ts

@@ -14,17 +14,82 @@ export interface Person {
   socialInsuranceCode?: string
   /** 街镇当前有效月补标准;该镇无标准时为 undefined,勿展示陈旧值 */
   standard?: number
+  /** 享受/代发起始年月 YYYY-MM */
   startMonth: string
   status: string
   source: string
   insuranceStatus: string
   remark?: string
   /**
-   * 退养资格类型(镇经发中心初审核验)
+   * 退养资格类型(镇经发中心初审核验;与 status/bizStatus 分离展示
    * - 有土地退养:确有承包地/退养土地
    * - 无土地有资格:无土地但按规定仍具备退养补助资格
+   * - 无资格:不具备退养补助资格,批次须驳回由村处理
    */
-  retireQualifyType?: '有土地退养' | '无土地有资格'
+  retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
+  /** —— 以下对齐人员表 / PersonnelVO 全字段(展示用;后续按需隐藏) —— */
+  phoneNumber?: string
+  address?: string
+  /** 户籍性质:农业/非农 */
+  householdType?: string
+  districtId?: string
+  townId?: string
+  villageId?: string
+  villageCode?: string
+  entrustPlanCode?: string
+  entrustPlanName?: string
+  payeeName?: string
+  bankType?: string
+  bankBranchCode?: string
+  /** 代发截止年月 YYYY-MM */
+  enjoyEndMonth?: string
+  /** 补发起始年月 YYYY-MM */
+  supplementStartMonth?: string
+  /** 下次发放起始年月 YYYY-MM */
+  nextPayStartMonth?: string
+  baseAmount?: number
+  oneTimeAmount?: number
+  /** 居保匹配原始码 SUCCESS/FAIL 等 */
+  insuranceMatchResult?: string
+  bankCardConfirmStatus?: string
+  /** 业务状态原始码 NORMAL/DRAFT 等 */
+  bizStatus?: string
+  payThisMonth?: boolean
+  inputVillageId?: string
+  /** 死亡日期 yyyy-MM-dd */
+  deathDate?: string
+  heirName?: string
+  /** 继承人与逝者关系 */
+  heirRelation?: string
+  heirIdNumber?: string
+  heirBankCard?: string
+  heirPhone?: string
+  funeralDiffAmount?: number
+  landSubsidyIncrease?: number
+  createTime?: string
+  updateTime?: string
+}
+
+/** 批次人员行:人员底数 + 本批次发放明细金额拆分 */
+export interface BatchMemberPerson extends Person {
+  /** 发放明细 ID */
+  paymentDetailId?: string
+  /** 本行月补标准(元/月),对应明细 monthlyAmount */
+  monthlyAmount?: number
+  /** 一次性代发 */
+  oneTimeAmount?: number
+  /** 调整/恢复等补发 */
+  supplementAmount?: number
+  /** 丧葬费 */
+  funeralAmount?: number
+  /** 扣减 */
+  deductAmount?: number
+  /** 应发合计(本批次该人) */
+  payableAmount?: number
+  /** 实发(回盘后) */
+  actualAmount?: number
+  /** 回盘结果文案 */
+  returnResult?: string
 }
 
 /** 镇经发中心:本批次各村待初审汇总 */
@@ -42,7 +107,7 @@ export interface TownPendingPersonItem {
   name: string
   idCard: string
   village: string
-  retireQualifyType?: '有土地退养' | '无土地有资格'
+  retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
   status: string
   source: string
   remark?: string
@@ -99,13 +164,15 @@ export interface VillageBatch {
   deductAmount?: number
 }
 
-/** 批次流程时间线条目 */
+/** 批次/人员流程时间线条目 */
 export interface ProcessLogItem {
   id: string
   batchId?: string
   batchNo?: string
   stage?: string
   action?: string
+  /** 展示用动作文案 */
+  actionLabel?: string
   approveNode?: string
   operator?: string
   operatorRole?: string
@@ -115,6 +182,8 @@ export interface ProcessLogItem {
   status?: string
   operateTime?: string
   fileUrls?: string
+  remarks?: string
+  bizType?: string
 }
 
 /** 批次流程总览 */
@@ -180,6 +249,17 @@ export interface Approval {
   createdAt: string
   /** 批次申报人数(来自批次头,联调展示用) */
   peopleCount?: number
+  /** 批次总发放金额(批次头或按明细汇总) */
+  totalAmount?: number
+  /** 特殊业务:人员姓名 / 说明 / 附件 / 死亡日期 */
+  personName?: string
+  personnelId?: string
+  reason?: string
+  fileUrls?: string
+  deathDate?: string
+  amount?: number
+  heirName?: string
+  idCard?: string
 }
 
 export interface SpecialBiz {
@@ -194,6 +274,7 @@ export interface SpecialBiz {
   status: string
   heirName?: string
   createdAt: string
+  fileUrls?: string
 }
 
 /** 调标补发名单(按村 Tab 展示) */
@@ -220,6 +301,8 @@ export interface DiskRecord {
   id: string
   batchNo: string
   town: string
+  /** 村居(村级批次) */
+  village?: string
   /** 线上清单文件名 */
   fileName: string
   peopleCount: number
@@ -227,7 +310,8 @@ export interface DiskRecord {
   /**
    * 清单状态:
    * - 待回盘(未上传回盘文件)
-   * - 已回盘(已上传回盘文件)
+   * - 已回盘(已上传/确认回盘,待归档)
+   * - 已归档(本月业务闭环结束)
    */
   status: string
   /**
@@ -259,17 +343,22 @@ export interface DiskFailPerson {
   payStatus: string
 }
 
-/** 死亡人员录入(镇社区事务受理服务中心 / 镇经发中心) */
+/** 死亡人员录入(特殊业务 FUNERAL,待分管领导审批后改人员状态) */
 export interface DeathRecord {
   id: string
+  personnelId?: string
   personName: string
   idCard: string
   town: string
   village: string
   deathDate: string
   source: string
+  /** 审批状态:待分管领导审批 / 已通过 / 已驳回 */
   status: string
   createdAt: string
+  opinion?: string
+  reason?: string
+  fileUrls?: string
   /** 继承人姓名 */
   heirName?: string
   /** 继承人身份证号 */
@@ -280,10 +369,24 @@ export interface DeathRecord {
   heirContact?: string
 }
 
+/** 告警生命周期节点 */
+export interface AlertLifecycleItem {
+  action?: string
+  actionLabel?: string
+  status?: string
+  operator?: string
+  result?: string
+  remark?: string
+  fileUrls?: string
+  operateTime?: string
+}
+
 export interface AlertItem {
   id: string
   batchNo: string
   personName: string
+  /** 关联人员ID */
+  personnelId?: string
   town: string
   village: string
   type: string
@@ -310,6 +413,12 @@ export interface AlertItem {
   heirBankCard?: string
   /** 继承人联系方式 */
   heirContact?: string
+  /** 生命周期(产生→处理→关闭) */
+  lifecycle?: AlertLifecycleItem[]
+  handler?: string
+  handleTime?: string
+  remarks?: string
+  fileUrls?: string
 }
 
 /** 处理告警提交载荷 */
@@ -327,6 +436,10 @@ export interface HandleAlertPayload {
   heirBankCard: string
   heirContact: string
   result?: string
+  /** 处理备注(写入 lifecycle) */
+  remarks?: string
+  /** 附件 URL,逗号分隔 */
+  fileUrls?: string
 }
 
 export interface AccountItem {
@@ -351,6 +464,8 @@ export interface OpLog {
 
 export interface SubsidyStandard {
   id: number
+  /** DMS 内容 ID(删除/更新用) */
+  rawId?: string
   town: string
   /** 适用年度 */
   year: number
@@ -359,6 +474,8 @@ export interface SubsidyStandard {
   /** 生效日期 YYYY-MM-DD */
   date: string
   reason: string
+  /** 是否当前有效 */
+  current?: boolean
 }
 
 export interface DashboardStats {

+ 54 - 0
src/utils/batchAmounts.ts

@@ -0,0 +1,54 @@
+import type { BatchMemberPerson } from '@/types/biz'
+
+/** 批次人员金额汇总(按发放明细逐人累加) */
+export interface BatchAmountSummary {
+  count: number
+  monthly: number
+  oneTime: number
+  supplement: number
+  funeral: number
+  deduct: number
+  /** 总发放/应发合计 */
+  payable: number
+}
+
+export function sumBatchMemberAmounts(
+  list: Array<Pick<
+    BatchMemberPerson,
+    | 'monthlyAmount'
+    | 'standard'
+    | 'oneTimeAmount'
+    | 'supplementAmount'
+    | 'funeralAmount'
+    | 'deductAmount'
+    | 'payableAmount'
+  >>,
+): BatchAmountSummary {
+  const s: BatchAmountSummary = {
+    count: list.length,
+    monthly: 0,
+    oneTime: 0,
+    supplement: 0,
+    funeral: 0,
+    deduct: 0,
+    payable: 0,
+  }
+  for (const p of list) {
+    const monthly = Number(p.monthlyAmount ?? p.standard ?? 0) || 0
+    const oneTime = Number(p.oneTimeAmount ?? 0) || 0
+    const supplement = Number(p.supplementAmount ?? 0) || 0
+    const funeral = Number(p.funeralAmount ?? 0) || 0
+    const deduct = Number(p.deductAmount ?? 0) || 0
+    let payable = Number(p.payableAmount)
+    if (!Number.isFinite(payable)) {
+      payable = monthly + oneTime + supplement + funeral - deduct
+    }
+    s.monthly += monthly
+    s.oneTime += oneTime
+    s.supplement += supplement
+    s.funeral += funeral
+    s.deduct += deduct
+    s.payable += payable
+  }
+  return s
+}

+ 53 - 0
src/utils/bizFiles.ts

@@ -0,0 +1,53 @@
+/** 业务附件 URL 解析与展示工具 */
+
+export type BizFileLink = {
+  href: string
+  name: string
+  isImage: boolean
+}
+
+const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i
+
+export function resolveBizFileHref(url: string): string {
+  const u = url.trim()
+  if (!u) return ''
+  if (/^https?:\/\//i.test(u) || u.startsWith('mock://') || u.startsWith('blob:')) return u
+  if (u.startsWith('/api/')) return u
+  if (u.startsWith('/files/')) return `/api${u}`
+  if (u.startsWith('api/')) return `/${u}`
+  return u.startsWith('/') ? u : `/api/files/${u.replace(/^\/+/, '')}`
+}
+
+export function stripEncodedFileUrls(raw?: string): string {
+  if (!raw) return ''
+  let s = raw.trim()
+  if (s.startsWith('FILES:')) s = s.slice('FILES:'.length)
+  const heir = s.indexOf('|HEIR:')
+  if (heir >= 0) s = s.slice(0, heir)
+  return s.trim()
+}
+
+export function parseBizFileLinks(raw?: string | null): BizFileLink[] {
+  const cleaned = stripEncodedFileUrls(raw || '')
+  if (!cleaned) return []
+  return cleaned
+    .split(/[,;\n]/)
+    .map((s) => s.trim())
+    .filter(Boolean)
+    .map((url, i) => {
+      const href = resolveBizFileHref(url)
+      const seg = url.split('/').pop() || `附件${i + 1}`
+      let name = seg
+      try {
+        name = decodeURIComponent(seg)
+      } catch {
+        /* keep */
+      }
+      return { href, name, isImage: IMAGE_EXT.test(name) || IMAGE_EXT.test(url) }
+    })
+    .filter((f) => !!f.href)
+}
+
+export function isBizImageUrl(url: string): boolean {
+  return IMAGE_EXT.test(url)
+}

+ 32 - 0
src/utils/fundShare.ts

@@ -0,0 +1,32 @@
+/** 区镇资金分担(与可研示例及后台 RegionVO 默认规则对齐) */
+
+export function defaultShareByTownName(town?: string): { district: number; town: number } {
+  const n = String(town || '')
+  if (n.includes('叶榭')) return { district: 90, town: 10 }
+  if (n.includes('佘山') || n.includes('车墩')) return { district: 60, town: 40 }
+  return { district: 50, town: 50 }
+}
+
+export function splitByShare(
+  amount: number,
+  districtPercent?: number | null,
+  townPercent?: number | null,
+  townName?: string,
+) {
+  const def = defaultShareByTownName(townName)
+  const d = districtPercent == null || Number.isNaN(Number(districtPercent))
+    ? def.district
+    : Number(districtPercent)
+  const t = townPercent == null || Number.isNaN(Number(townPercent)) ? def.town : Number(townPercent)
+  const sum = d + t
+  const dp = sum > 0 ? d / sum : 0.5
+  const tp = sum > 0 ? t / sum : 0.5
+  const total = Number(amount) || 0
+  return {
+    districtPercent: d,
+    townPercent: t,
+    shareLabel: `${d}/${t}`,
+    districtAmount: Math.round(total * dp * 100) / 100,
+    townAmount: Math.round(total * tp * 100) / 100,
+  }
+}

+ 23 - 0
src/utils/request.ts

@@ -88,9 +88,32 @@ service.interceptors.response.use(
   },
 )
 
+/** 静态站 SPA fallback 常把接口路径回成 index.html;当作列表会触发 el-table reduce 报错 */
+function assertJsonApiBody(res: AxiosResponse, config: RequestConfig) {
+  const body = res.data
+  const ct = String(res.headers?.['content-type'] || '')
+  const looksHtml =
+    (typeof body === 'string' &&
+      /^\s*</.test(body) &&
+      /<!doctype|<html/i.test(body)) ||
+    (ct.includes('text/html') && typeof body === 'string')
+  if (!looksHtml) return
+  const base = getAppConfig().apiBase || '/api'
+  const url = `${base}${config.url || ''}`
+  const err = new Error(
+    `接口返回了 HTML 页面而非 JSON(常见于 apiBase/反向代理未指向后端):${url}`,
+  )
+  const silent = Boolean(config.silent)
+  if (!silent) {
+    ElMessage.error(err.message)
+  }
+  throw err
+}
+
 /** 通用请求方法,返回业务 data */
 function request<T = unknown>(config: RequestConfig): Promise<T> {
   return service.request<ApiResponse<T>>(config).then((res) => {
+    assertJsonApiBody(res, config)
     const data = res.data
     if (data && typeof data === 'object' && 'data' in data) {
       return data.data as T

+ 164 - 0
src/utils/statusLabels.ts

@@ -0,0 +1,164 @@
+/**
+ * 统一业务 code → 中文展示入口。
+ * 图表/Tag/列表禁止直接把英文枚举抛给用户;未知 code 显示「未知(code)」。
+ */
+
+export type StatusDictType =
+  | 'batchStatus'
+  | 'flowStage'
+  | 'personnelStatus'
+  | 'insuranceMatch'
+  | 'alertHandleStatus'
+  | 'alertType'
+  | 'alertLevel'
+  | 'specialBizType'
+
+const BATCH_STATUS: Record<string, string> = {
+  DRAFT: '草稿',
+  PENDING_TOWN_APPROVE: '待镇经发初审',
+  PENDING_INSURANCE_MATCH: '待居保匹配',
+  PENDING_STATUS_MAINTAIN: '待状态维护',
+  PENDING_LEADER_APPROVE: '待分管领导审批',
+  READY_EXPORT: '可出盘',
+  EXPORTED: '已出盘',
+  RETURNED: '已回盘',
+  ARCHIVED: '已归档',
+  REJECTED: '已驳回',
+}
+
+/** 流程阶段(与业务状态语义分离;EXPORTED 在流程上多为「回盘信息确认」) */
+const FLOW_STAGE: Record<string, string> = {
+  STAGE1_PERSONNEL_INPUT: '人员新增录入',
+  STAGE2_TOWN_PRECHECK: '镇经发中心初审',
+  STAGE3_INSURANCE_MATCH: '居保状态匹配',
+  STAGE4_MONTHLY_LIST: '生成月补贴清单',
+  STAGE5_LEADER_ONLINE_APPROVE: '分管领导审批',
+  STAGE6_COMPARE_FILE: '生成对照文件',
+  STAGE7_OFFLINE_SIGN_PAY: '待线下发放',
+  STAGE8_RETURN_CONFIRM: '回盘信息确认',
+  DRAFT: '人员新增录入',
+  PENDING_TOWN_APPROVE: '镇经发中心初审',
+  PENDING_INSURANCE_MATCH: '居保状态匹配',
+  PENDING_STATUS_MAINTAIN: '比对结果已生成',
+  PENDING_LEADER_APPROVE: '分管领导审批',
+  READY_EXPORT: '待线下发放',
+  EXPORTED: '回盘信息确认',
+  RETURNED: '已回盘确认',
+  ARCHIVED: '已归档',
+  REJECTED: '退回修改',
+}
+
+const PERSONNEL_STATUS: Record<string, string> = {
+  DRAFT: '草稿',
+  PENDING_TOWN_APPROVE: '待镇初审',
+  PENDING_INSURANCE_MATCH: '待居保匹配',
+  NORMAL: '正常在库',
+  PAUSED: '暂停',
+  STOPPED: '停止',
+  FUNERAL: '死亡丧葬',
+  ABNORMAL: '异常待处理',
+  ARCHIVED: '已归档',
+}
+
+const INSURANCE_MATCH: Record<string, string> = {
+  PENDING: '未匹配',
+  SUCCESS: '匹配通过',
+  LOSE_ELIGIBILITY: '丧失资格',
+  FAIL: '匹配失败',
+  MANUAL_PASS: '匹配通过',
+}
+
+const ALERT_HANDLE: Record<string, string> = {
+  PENDING: '待处理',
+  PROCESSING: '处理中',
+  CLOSED: '已关闭',
+}
+
+const ALERT_TYPE: Record<string, string> = {
+  BANK_CARD_ABNORMAL: '银行卡异常',
+  NAME_MISMATCH: '姓名不一致',
+  INSURANCE_MATCH_FAIL: '居保匹配失败',
+  AMOUNT_ABNORMAL: '金额异常',
+  DEATH_STATUS: '死亡停发预警',
+  MATERIAL_PENDING: '材料待补',
+  RETURN_FAIL: '回盘失败',
+  LAND_MATCH_FAIL: '土地匹配不符合',
+  民政死亡比对: '死亡停发预警',
+  回盘发放失败: '回盘失败',
+}
+
+const ALERT_LEVEL: Record<string, string> = {
+  HIGH: '高',
+  MEDIUM: '中',
+  LOW: '低',
+}
+
+const SPECIAL_BIZ: Record<string, string> = {
+  FUNERAL: '丧葬补贴',
+  PAUSE: '暂停发放',
+  STOP: '停止发放',
+  RESUME: '恢复发放',
+  STANDARD_ADJUST: '调标差额补发',
+  SUPPLEMENT: '纳入下月补发',
+  REFUND: '多发退款',
+  HEIR_UPDATE: '继承人信息变更',
+  /** 流程留痕 bizType(非特殊业务单,但时间线会展示) */
+  INSURANCE_MATCH: '居保匹配',
+  PERSONNEL_STATUS: '人员状态变更',
+}
+
+const DICTS: Record<StatusDictType, Record<string, string>> = {
+  batchStatus: BATCH_STATUS,
+  flowStage: FLOW_STAGE,
+  personnelStatus: PERSONNEL_STATUS,
+  insuranceMatch: INSURANCE_MATCH,
+  alertHandleStatus: ALERT_HANDLE,
+  alertType: ALERT_TYPE,
+  alertLevel: ALERT_LEVEL,
+  specialBizType: SPECIAL_BIZ,
+}
+
+/** 是否像未翻译的英文枚举 code */
+function looksLikeCode(s: string) {
+  return /^[A-Z][A-Z0-9_]*$/.test(s)
+}
+
+/** 命中字典或已是中文则返回;未知英文 code 返回 null */
+export function tryLabelOf(dictType: StatusDictType, code?: string | null): string | null {
+  if (code == null || code === '') return null
+  const raw = String(code).trim()
+  const table = DICTS[dictType]
+  if (table[raw]) return table[raw]
+  const upper = raw.toUpperCase()
+  if (table[upper]) return table[upper]
+  if (!looksLikeCode(raw)) return raw
+  return null
+}
+
+/**
+ * @param dictType 字典册
+ * @param code 后端枚举或已是中文的文案
+ */
+export function labelOf(dictType: StatusDictType, code?: string | null): string {
+  if (code == null || code === '') return ''
+  const hit = tryLabelOf(dictType, code)
+  if (hit != null) return hit
+  const raw = String(code).trim()
+  if (import.meta.env.DEV) {
+    // eslint-disable-next-line no-console
+    console.warn(`[statusLabels] 未知 ${dictType} code:`, raw)
+  }
+  return `未知(${raw})`
+}
+
+export function batchStatusLabel(code?: string | null) {
+  return labelOf('batchStatus', code)
+}
+
+export function flowStageLabelOf(code?: string | null) {
+  return labelOf('flowStage', code)
+}
+
+export function personnelStatusLabel(code?: string | null) {
+  return labelOf('personnelStatus', code)
+}

+ 1 - 1
src/utils/statusTag.ts

@@ -5,7 +5,7 @@ export function statusTagType(status: string): 'success' | 'warning' | 'danger'
   if (status === '已审核' || status === '已出盘') return 'success'
   if (/通过|归档|完成|正常|已确认|已关闭|全部成功|成功|已回盘|已停止|资格已核验/.test(status)) return 'success'
   if (/待|草稿|未|初审|匹配|录入/.test(status)) return 'warning'
-  if (/驳回|失败|异常|死亡|停止|退回|暂停/.test(status)) return 'danger'
+  if (/驳回|失败|异常|死亡|停止|退回|暂停|无资格|历史/.test(status)) return 'danger'
   return 'info'
 }
 

+ 31 - 0
src/utils/validators.ts

@@ -0,0 +1,31 @@
+/** 人员录入常用校验 */
+
+/** 18 位身份证(含末位 X) */
+export const ID_CARD_PATTERN = /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
+
+/** 大陆手机号 */
+export const PHONE_PATTERN = /^1[3-9]\d{9}$/
+
+export function isValidIdCard(value?: string): boolean {
+  const s = String(value || '').trim()
+  return ID_CARD_PATTERN.test(s)
+}
+
+export function isValidPhone(value?: string): boolean {
+  const s = String(value || '').trim()
+  if (!s) return false
+  return PHONE_PATTERN.test(s)
+}
+
+/** 空值视为通过(选填);有值则校验 */
+export function isValidPhoneOptional(value?: string): boolean {
+  const s = String(value || '').trim()
+  if (!s) return true
+  return PHONE_PATTERN.test(s)
+}
+
+export function isValidIdCardOptional(value?: string): boolean {
+  const s = String(value || '').trim()
+  if (!s) return true
+  return ID_CARD_PATTERN.test(s)
+}

+ 36 - 5
src/views/admin/AdminBatchesView.vue

@@ -43,13 +43,42 @@
         <el-table-column label="合计" width="110" align="right">
           <template #default="{ row }">{{ formatAmt(row.totalAmount) }}</template>
         </el-table-column>
-        <el-table-column prop="status" label="状态" width="140" show-overflow-tooltip />
-        <el-table-column prop="currentStage" label="阶段" width="150" show-overflow-tooltip />
+        <el-table-column label="状态" width="140" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="batchStatusLabel((row as AdminBatch).status)" />
+          </template>
+        </el-table-column>
+        <el-table-column label="阶段" min-width="150" show-overflow-tooltip>
+          <template #default="{ row }">
+            {{ flowStageLabelOf((row as AdminBatch).currentStage) }}
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="200" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button link type="primary" size="small" @click="open(row, 'detail')">详情</el-button>
-            <el-button link type="primary" size="small" @click="open(row, 'flow')">流程</el-button>
-            <el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="open(row as AdminBatch, 'detail')"
+            >
+              详情
+            </el-button>
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="open(row as AdminBatch, 'flow')"
+            >
+              流程
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              size="small"
+              @click="onDelete(row as AdminBatch)"
+            >
+              删除
+            </el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -71,7 +100,9 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import { listRegionsAdmin, pageAdminBatches, purgeBatchAdmin } from '@/api/biz'
+import StatusTag from '@/components/common/StatusTag.vue'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { batchStatusLabel, flowStageLabelOf } from '@/utils/statusLabels'
 import type { RegionRow } from '@/api/sjnmtybt/regionStore'
 
 type AdminBatch = {

+ 138 - 2
src/views/admin/PersonnelAdminView.vue

@@ -2,7 +2,7 @@
   <div class="personnel-admin-view">
     <PagePanel title="人员底数管理">
       <p class="tip">
-        全区人员底数查询与运维。可变更业务状态、删除(归档)。日常新增录入仍建议在「人员新增录入」按村办理。
+        全区人员底数查询与运维。可修改基础信息、变更业务状态、删除(归档)。日常新增录入仍建议在「人员新增录入」按村办理。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -47,8 +47,10 @@
             <StatusTag :status="row.insuranceStatus || '未匹配'" />
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="200" align="center" fixed="right">
+        <el-table-column label="操作" width="300" align="center" fixed="right">
           <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
+            <el-button link type="primary" size="small" @click="openEdit(row)">修改</el-button>
             <el-button link type="warning" size="small" @click="openStatus(row)">变更状态</el-button>
             <el-button link type="danger" size="small" @click="onDelete(row)">删除</el-button>
           </template>
@@ -57,6 +59,41 @@
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
+    <el-dialog
+      v-model="detailDlg"
+      :title="detailPerson ? `人员详情 · ${detailPerson.name}` : '人员详情'"
+      width="920px"
+      destroy-on-close
+      @closed="detailPerson = null"
+    >
+      <div v-loading="detailLoading">
+        <PersonnelDetailDescriptions v-if="detailPerson" :person="detailPerson" />
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="detailDlg = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="editDlg" title="修改人员信息" width="920px" destroy-on-close>
+      <p class="dialog-tip">
+        展示人员表全部字段;灰色为系统只读。保存后业务状态与居保匹配结果保持不变(退养资格除外)。
+      </p>
+      <el-form v-loading="editLoading" :model="editForm" label-width="140px" size="small">
+        <PersonnelFormFields
+          :model="editForm"
+          mode="edit"
+          :town-options="townOptions"
+          :village-options="editVillageOptions"
+          @update:model="onEditFormUpdate"
+          @town-change="onEditTownChange"
+        />
+      </el-form>
+      <template #footer>
+        <el-button @click="editDlg = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
+      </template>
+    </el-dialog>
+
     <el-dialog v-model="statusDlg" title="变更人员状态" width="420px" destroy-on-close>
       <el-form label-width="96px" size="small">
         <el-form-item label="人员">{{ current?.name }}</el-form-item>
@@ -92,13 +129,21 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
+import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
+import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
   deletePersonnelAdmin,
   fetchPeople,
+  fetchPersonDetail,
+  savePerson,
   updatePersonnelStatusAdmin,
 } from '@/api/biz'
+import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
+import { ensureVillages, listVillageNames } from '@/api/sjnmtybt/regionStore'
+import { getAppConfig } from '@/utils/appConfig'
+import { getVillagesByTown } from '@/api/mock/data'
 import { maskIdCard } from '@/utils/mask'
 import type { Person } from '@/types/biz'
 
@@ -116,6 +161,11 @@ const list = ref<Person[]>([])
 const loading = ref(false)
 const saving = ref(false)
 const statusDlg = ref(false)
+const editDlg = ref(false)
+const editLoading = ref(false)
+const detailDlg = ref(false)
+const detailLoading = ref(false)
+const detailPerson = ref<Person | null>(null)
 const current = ref<Person | null>(null)
 const statusForm = ref({
   bizStatus: 'NORMAL',
@@ -123,8 +173,44 @@ const statusForm = ref({
   remarks: '',
 })
 
+const editForm = ref(emptyPersonForm())
+const editVillageOptions = ref<string[]>([])
+
+function onEditFormUpdate(v: Person) {
+  editForm.value = v
+}
+
 const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
 
+async function loadEditVillages(town: string) {
+  if (!town) {
+    editVillageOptions.value = []
+    return
+  }
+  if (getAppConfig().useMock) {
+    editVillageOptions.value = getVillagesByTown(town)
+    return
+  }
+  await ensureVillages(town)
+  editVillageOptions.value = listVillageNames(town)
+}
+
+async function onEditTownChange(town?: string) {
+  await loadEditVillages(town ?? editForm.value.town)
+}
+
+async function openDetail(row: Person) {
+  detailPerson.value = row
+  detailDlg.value = true
+  detailLoading.value = true
+  try {
+    const full = await fetchPersonDetail(String(row.id))
+    if (full) detailPerson.value = full
+  } finally {
+    detailLoading.value = false
+  }
+}
+
 async function search() {
   loading.value = true
   try {
@@ -149,6 +235,50 @@ function reset() {
   search()
 }
 
+async function openEdit(row: Person) {
+  current.value = row
+  editForm.value = personToForm(row)
+  editDlg.value = true
+  editLoading.value = true
+  try {
+    const full = await fetchPersonDetail(String(row.id))
+    if (full) editForm.value = personToForm(full)
+    await loadEditVillages(editForm.value.town)
+  } finally {
+    editLoading.value = false
+  }
+}
+
+async function saveEdit() {
+  const f = editForm.value
+  if (!f.town || !f.village) {
+    ElMessage.warning('请选择街镇与村居')
+    return
+  }
+  if (!f.name?.trim() || !f.idCard?.trim() || !f.bankCard?.trim()) {
+    ElMessage.warning('姓名、身份证号、银行卡号为必填')
+    return
+  }
+  saving.value = true
+  try {
+    await savePerson({
+      ...f,
+      id: f.id,
+      town: f.town,
+      village: f.village,
+      name: f.name.trim(),
+      idCard: f.idCard.trim(),
+      bankCard: f.bankCard.trim(),
+      source: f.source || '管理员修改',
+    })
+    ElMessage.success('人员信息已保存')
+    editDlg.value = false
+    await search()
+  } finally {
+    saving.value = false
+  }
+}
+
 function openStatus(row: Person) {
   current.value = row
   statusForm.value = {
@@ -202,6 +332,12 @@ onMounted(search)
   color: @text-secondary;
   line-height: 1.5;
 }
+.dialog-tip {
+  margin: 0 0 12px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
 .query-panel {
   margin-bottom: 10px;
 }

+ 47 - 3
src/views/admin/RegionsAdminView.vue

@@ -34,6 +34,14 @@
           <template #default="{ row }">{{ parentLabel(row.parentId) }}</template>
         </el-table-column>
         <el-table-column prop="entrustPlanCode" label="委托方案代码" min-width="120" />
+        <el-table-column label="区/镇分担(%)" width="120" align="center">
+          <template #default="{ row }">
+            <template v-if="row.level === 'TOWN'">
+              {{ row.districtSharePercent ?? '—' }}/{{ row.townSharePercent ?? '—' }}
+            </template>
+            <template v-else>—</template>
+          </template>
+        </el-table-column>
         <el-table-column label="启用" width="70" align="center">
           <template #default="{ row }">{{ row.enabled === false ? '否' : '是' }}</template>
         </el-table-column>
@@ -74,6 +82,31 @@
         <el-form-item label="默认银行">
           <el-input v-model="form.defaultBank" />
         </el-form-item>
+        <template v-if="form.level === 'TOWN'">
+          <el-form-item label="区承担比例%">
+            <el-input-number
+              v-model="form.districtSharePercent"
+              :min="0"
+              :max="100"
+              :controls="false"
+              style="width: 100%"
+              placeholder="如叶榭90"
+            />
+          </el-form-item>
+          <el-form-item label="镇承担比例%">
+            <el-input-number
+              v-model="form.townSharePercent"
+              :min="0"
+              :max="100"
+              :controls="false"
+              style="width: 100%"
+              placeholder="如叶榭10"
+            />
+          </el-form-item>
+          <p class="tip" style="margin: -6px 0 10px">
+            缺省:叶榭 90/10,佘山/车墩 60/40,其余 50/50。区+镇宜为 100。
+          </p>
+        </template>
         <el-form-item label="启用">
           <el-switch v-model="form.enabled" />
         </el-form-item>
@@ -118,6 +151,8 @@ const form = reactive({
   defaultBank: '',
   enabled: true,
   remarks: '',
+  districtSharePercent: undefined as number | undefined,
+  townSharePercent: undefined as number | undefined,
 })
 
 const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
@@ -133,7 +168,8 @@ function parentLabel(parentId?: string) {
 }
 
 async function loadTowns() {
-  towns.value = await listRegionsAdmin({ level: 'TOWN', parentId: 'D_SJ' })
+  const rows = await listRegionsAdmin({ level: 'TOWN', parentId: 'D_SJ' })
+  towns.value = Array.isArray(rows) ? rows : []
 }
 
 async function search() {
@@ -142,7 +178,8 @@ async function search() {
     const params: { level: string; parentId?: string } = { level: q.level }
     if (q.level === 'VILLAGE' && q.parentId) params.parentId = q.parentId
     if (q.level === 'TOWN') params.parentId = 'D_SJ'
-    list.value = await listRegionsAdmin(params)
+    const rows = await listRegionsAdmin(params)
+    list.value = Array.isArray(rows) ? rows : []
     resetPage()
   } finally {
     loading.value = false
@@ -165,6 +202,8 @@ function resetForm() {
     defaultBank: '',
     enabled: true,
     remarks: '',
+    districtSharePercent: undefined,
+    townSharePercent: undefined,
   })
 }
 
@@ -173,7 +212,7 @@ function openCreate() {
   dlg.value = true
 }
 
-function openEdit(row: RegionRow & { entrustPlanCode?: string; defaultBank?: string; enabled?: boolean; remarks?: string }) {
+function openEdit(row: RegionRow) {
   Object.assign(form, {
     id: row.id || '',
     code: row.code || row.id || '',
@@ -184,6 +223,8 @@ function openEdit(row: RegionRow & { entrustPlanCode?: string; defaultBank?: str
     defaultBank: row.defaultBank || '',
     enabled: row.enabled !== false,
     remarks: row.remarks || '',
+    districtSharePercent: row.districtSharePercent,
+    townSharePercent: row.townSharePercent,
   })
   dlg.value = true
 }
@@ -209,6 +250,9 @@ async function save() {
       defaultBank: form.defaultBank || undefined,
       enabled: form.enabled,
       remarks: form.remarks || undefined,
+      districtSharePercent:
+        form.level === 'TOWN' ? form.districtSharePercent : undefined,
+      townSharePercent: form.level === 'TOWN' ? form.townSharePercent : undefined,
     })
     ElMessage.success('已保存')
     dlg.value = false

+ 261 - 187
src/views/alerts/AlertsView.vue

@@ -33,7 +33,7 @@
         <el-table-column prop="village" label="村居" width="90" />
         <el-table-column prop="type" label="告警类型" width="120">
           <template #default="{ row }">
-            <span :class="{ 'alerts-view__danger-type': row.type === '民政死亡比对' }">
+            <span :class="{ 'alerts-view__danger-type': isDeathAlertType(row.type) }">
               {{ row.type }}
             </span>
           </template>
@@ -51,12 +51,15 @@
         </el-table-column>
         <el-table-column prop="result" label="处理结果" min-width="140" show-overflow-tooltip />
         <el-table-column prop="createdAt" label="产生时间" width="140" />
-        <el-table-column label="操作" width="100" align="center" fixed="right">
+        <el-table-column label="操作" width="160" align="center" fixed="right">
           <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="openDetailDialog(row as AlertItem)">
+              详情
+            </el-button>
             <el-button
               v-if="!userStore.alertReadonly && row.status !== '已关闭'"
               link
-              type="primary"
+              type="warning"
               size="small"
               @click="openHandleDialog(row as AlertItem)"
             >
@@ -72,12 +75,12 @@
     <el-dialog
       v-model="dialogVisible"
       title="处理告警"
-      width="680px"
+      width="920px"
       destroy-on-close
       @closed="onDialogClosed"
     >
       <p v-if="currentAlert" class="alerts-view__dialog-meta">
-        告警类型:<strong :class="{ 'alerts-view__danger-type': currentAlert.type === '民政死亡比对' }">
+        告警类型:<strong :class="{ 'alerts-view__danger-type': isDeathAlertType(currentAlert.type) }">
           {{ currentAlert.type }}
         </strong>
         · 批次 {{ currentAlert.batchNo }} · {{ currentAlert.content }}
@@ -101,120 +104,45 @@
         </ul>
       </el-alert>
       <p class="alerts-view__dialog-tip">
-        请核实并修正人员信息后提交;姓名、身份证号、街镇、村居、银行卡号为必填。社会保险登记码建议填写以便居保匹配。继承人相关信息为选填,涉及丧葬/死亡类告警时建议一并登记。提交后告警将关闭,村合作社与镇社区事务受理服务中心状态同步更新。
+        请核实并修正人员信息后提交(展示人员表全部字段)。姓名、身份证号、街镇、村居、银行卡号为必填。涉及丧葬/死亡类告警时请登记继承人信息。处理备注建议填写,证明材料可上传。提交后告警将关闭,村/镇状态同步更新。
       </p>
-      <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" size="small">
-        <el-divider content-position="left">人员信息</el-divider>
-        <el-row :gutter="12">
-          <el-col :span="12">
-            <el-form-item label="姓名" prop="personName">
-              <el-input v-model="form.personName" class="is-editable" placeholder="请输入姓名" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="身份证号" prop="idCard">
-              <el-input
-                v-model="form.idCard"
-                class="is-editable"
-                placeholder="请输入18位身份证号"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="街镇" prop="town">
-              <el-select
-                v-if="isAdmin"
-                v-model="form.town"
-                class="is-editable"
-                placeholder="请选择街镇"
-                filterable
-                style="width: 100%"
-                @change="onFormTownChange"
-              >
-                <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
-              </el-select>
-              <el-input v-else v-model="form.town" class="is-editable" placeholder="请输入街镇" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="村居" prop="village">
-              <el-select
-                v-if="isAdmin"
-                v-model="form.village"
-                class="is-editable"
-                placeholder="请先选择街镇"
-                filterable
-                style="width: 100%"
-              >
-                <el-option v-for="v in formVillageOptions" :key="v" :label="v" :value="v" />
-              </el-select>
-              <el-input v-else v-model="form.village" class="is-editable" placeholder="请输入村居" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="银行卡号" prop="bankCard">
-              <el-input
-                v-model="form.bankCard"
-                class="is-editable"
-                placeholder="请输入银行卡号"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="开户行">
-              <el-input
-                v-model="form.bankName"
-                class="is-editable"
-                placeholder="选填,如工商银行叶榭支行"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="社会保险登记码">
-              <el-input
-                v-model="form.socialInsuranceCode"
-                class="is-editable"
-                placeholder="选填,建议填写以便居保匹配"
-              />
-            </el-form-item>
-          </el-col>
-        </el-row>
-
-        <el-divider content-position="left">继承人信息(选填)</el-divider>
-        <el-row :gutter="12">
-          <el-col :span="12">
-            <el-form-item label="继承人姓名" prop="heirName">
-              <el-input v-model="form.heirName" class="is-editable" placeholder="选填" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="继承人身份证号" prop="heirIdCard">
-              <el-input
-                v-model="form.heirIdCard"
-                class="is-editable"
-                placeholder="选填"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="继承人银行卡号" prop="heirBankCard">
-              <el-input
-                v-model="form.heirBankCard"
-                class="is-editable"
-                placeholder="选填"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="继承人联系方式" prop="heirContact">
-              <el-input
-                v-model="form.heirContact"
-                class="is-editable"
-                placeholder="选填,手机号或其他联系方式"
-              />
-            </el-form-item>
-          </el-col>
-        </el-row>
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" size="small">
+        <PersonnelFormFields
+          :model="form"
+          mode="edit"
+          :town-options="townOptions"
+          :village-options="formVillageOptions"
+          :town-disabled="!isAdmin"
+          :village-disabled="!isAdmin"
+          @update:model="onAlertFormUpdate"
+          @town-change="onFormTownChange"
+        />
+        <el-divider content-position="left">处理说明</el-divider>
+        <el-form-item label="处理备注" required>
+          <el-input
+            v-model="handleRemarks"
+            type="textarea"
+            :rows="2"
+            class="is-editable"
+            placeholder="请填写核实情况与处理说明(将写入处理流程)"
+          />
+        </el-form-item>
+        <el-form-item label="证明材料">
+          <el-upload
+            v-model:file-list="handleFileList"
+            :auto-upload="false"
+            :limit="5"
+            accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
+            multiple
+            @change="syncHandleFiles"
+            @remove="syncHandleFiles"
+          >
+            <el-button size="small" type="primary">选择附件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持图片/PDF/Office,最多 5 个</div>
+            </template>
+          </el-upload>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="dialogVisible = false">取消</el-button>
@@ -223,17 +151,71 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <!-- 告警详情 + 处理流程时间线 -->
+    <el-dialog
+      v-model="detailVisible"
+      :title="detailAlert ? `告警详情 · ${detailAlert.personName || detailAlert.id}` : '告警详情'"
+      width="720px"
+      destroy-on-close
+      @closed="onDetailClosed"
+    >
+      <template v-if="detailAlert">
+        <el-descriptions :column="2" border size="small" class="alerts-view__detail-desc">
+          <el-descriptions-item label="批次号">{{ detailAlert.batchNo || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <StatusTag :status="detailAlert.status" />
+          </el-descriptions-item>
+          <el-descriptions-item label="类型">{{ detailAlert.type }}</el-descriptions-item>
+          <el-descriptions-item label="等级">
+            <StatusTag :status="detailAlert.level" mode="level" />
+          </el-descriptions-item>
+          <el-descriptions-item label="街镇/村居">
+            {{ detailAlert.town }} / {{ detailAlert.village }}
+          </el-descriptions-item>
+          <el-descriptions-item label="产生时间">{{ detailAlert.createdAt || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="告警内容" :span="2">
+            {{ detailAlert.content || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="处理结果" :span="2">
+            {{ detailAlert.result || '—' }}
+          </el-descriptions-item>
+        </el-descriptions>
+        <div class="alerts-view__lifecycle-title">处理流程</div>
+        <AlertLifecycleTimeline :lifecycle="detailLifecycle" :loading="detailLoading" />
+      </template>
+      <template #footer>
+        <el-button
+          v-if="detailAlert && !userStore.alertReadonly && detailAlert.status !== '已关闭'"
+          type="warning"
+          @click="openHandleFromDetail"
+        >
+          去处理
+        </el-button>
+        <el-button type="primary" @click="detailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref, watch } from 'vue'
-import type { FormInstance, FormRules } from 'element-plus'
+import { computed, onMounted, ref, watch } from 'vue'
+import type { FormInstance, FormRules, UploadFile, UploadUserFile } from 'element-plus'
 import { ElMessage } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
-import { fetchAlerts, fetchPeople, handleAlert } from '@/api/biz'
+import AlertLifecycleTimeline from '@/components/biz/AlertLifecycleTimeline.vue'
+import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
+import {
+  fetchAlertDetail,
+  fetchAlerts,
+  fetchPeople,
+  fetchPersonDetail,
+  handleAlert,
+  savePerson,
+  uploadBizFile,
+} from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
 import {
   useDynamicRegionOptions,
@@ -245,7 +227,8 @@ import {
   DISK_RETURN_FAIL_REASONS,
   DISK_RETURN_FAIL_TIP,
 } from '@/constants/diskReturn'
-import type { AlertItem } from '@/types/biz'
+import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
+import type { AlertItem, AlertLifecycleItem, Person } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
@@ -282,36 +265,47 @@ const dialogVisible = ref(false)
 const saving = ref(false)
 const currentAlert = ref<AlertItem | null>(null)
 const formRef = ref<FormInstance>()
+const handleRemarks = ref('')
+const handleFileList = ref<UploadUserFile[]>([])
+const handleRawFiles = ref<File[]>([])
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detailAlert = ref<AlertItem | null>(null)
+const detailLifecycle = ref<AlertLifecycleItem[]>([])
 
 const diskFailReasons = DISK_RETURN_FAIL_REASONS
 const diskFailTip = DISK_RETURN_FAIL_TIP
-const isDiskFailAlert = computed(() => currentAlert.value?.type === '回盘发放失败')
-
-const form = reactive({
-  personName: '',
-  idCard: '',
-  town: '',
-  village: '',
-  bankCard: '',
-  bankName: '',
-  socialInsuranceCode: '',
-  heirName: '',
-  heirIdCard: '',
-  heirBankCard: '',
-  heirContact: '',
+function isDeathAlertType(type?: string) {
+  return type === '死亡停发预警' || type === '民政死亡比对' || type === 'DEATH_STATUS'
+}
+
+const isDiskFailAlert = computed(() => {
+  const t = currentAlert.value?.type
+  return t === '回盘失败' || t === '回盘发放失败' || t === 'RETURN_FAIL'
 })
 
-const formTownRef = computed(() => form.town)
+const form = ref(emptyPersonForm())
+
+function onAlertFormUpdate(v: Person) {
+  form.value = v
+}
+
+function syncHandleFiles(_file: UploadFile, fileList: UploadFile[]) {
+  handleFileList.value = fileList
+  handleRawFiles.value = fileList.map((f) => f.raw).filter((f) => !!f) as File[]
+}
+
+const formTownRef = computed(() => form.value.town)
 const { villageOptions: formVillageOptions, loadVillages: loadFormVillages } =
   useDynamicRegionOptions(formTownRef)
 
-async function onFormTownChange() {
-  form.village = ''
-  if (form.town) await loadFormVillages(form.town)
+async function onFormTownChange(town?: string) {
+  if (town) await loadFormVillages(town)
+  else if (form.value.town) await loadFormVillages(form.value.town)
 }
 
 const formRules: FormRules = {
-  personName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
+  name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
   idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
   town: [
     {
@@ -346,48 +340,53 @@ const tipText = computed(() => {
 })
 
 function resetForm() {
-  form.personName = ''
-  form.idCard = ''
-  form.town = ''
-  form.village = ''
-  form.bankCard = ''
-  form.bankName = ''
-  form.socialInsuranceCode = ''
-  form.heirName = ''
-  form.heirIdCard = ''
-  form.heirBankCard = ''
-  form.heirContact = ''
+  form.value = emptyPersonForm()
 }
 
 async function openHandleDialog(row: AlertItem) {
   currentAlert.value = row
-  resetForm()
-  form.personName = row.personName
-  form.town = row.town
-  form.village = row.village
-  form.idCard = row.idCard || ''
-  form.bankCard = row.bankCard || ''
-  form.bankName = row.bankName || ''
-  form.socialInsuranceCode = row.socialInsuranceCode || ''
-  form.heirName = row.heirName || ''
-  form.heirIdCard = row.heirIdCard || ''
-  form.heirBankCard = row.heirBankCard || ''
-  form.heirContact = row.heirContact || ''
-
-  // 从人员档案回填证件号、银行卡等信息
+  handleRemarks.value = ''
+  handleFileList.value = []
+  handleRawFiles.value = []
+  form.value = emptyPersonForm({
+    name: row.personName || '',
+    town: row.town || '',
+    village: row.village || '',
+    idCard: row.idCard || '',
+    bankCard: row.bankCard || '',
+    bankName: row.bankName || '',
+    socialInsuranceCode: row.socialInsuranceCode || '',
+    heirName: row.heirName || '',
+    heirIdNumber: row.heirIdCard || '',
+    heirBankCard: row.heirBankCard || '',
+    heirPhone: row.heirContact || '',
+  })
+
+  // 从人员档案回填全字段
   try {
-    const people = await fetchPeople()
-    const matched = people.find(
-      (p) => p.name === row.personName && p.town === row.town && p.village === row.village,
-    )
-    if (matched) {
-      if (!form.idCard) form.idCard = matched.idCard
-      if (!form.bankCard) form.bankCard = matched.bankCard
-      if (!form.bankName) form.bankName = matched.bankName
-      if (!form.socialInsuranceCode) {
-        form.socialInsuranceCode = matched.socialInsuranceCode || ''
+    let matched: Person | undefined
+    if (row.personnelId) {
+      matched = (await fetchPersonDetail(String(row.personnelId))) || undefined
+    }
+    if (!matched) {
+      const people = await fetchPeople({ town: row.town, village: row.village })
+      matched = people.find(
+        (p) => p.name === row.personName && p.town === row.town && p.village === row.village,
+      )
+      if (matched?.id) {
+        matched = (await fetchPersonDetail(String(matched.id))) || matched
       }
     }
+    if (matched) {
+      form.value = personToForm({
+        ...matched,
+        heirName: form.value.heirName || matched.heirName,
+        heirIdNumber: form.value.heirIdNumber || matched.heirIdNumber,
+        heirBankCard: form.value.heirBankCard || matched.heirBankCard,
+        heirPhone: form.value.heirPhone || matched.heirPhone,
+      })
+    }
+    if (form.value.town) await loadFormVillages(form.value.town)
   } catch {
     /* 回填失败不影响打开弹窗 */
   }
@@ -397,30 +396,94 @@ async function openHandleDialog(row: AlertItem) {
 
 function onDialogClosed() {
   currentAlert.value = null
+  handleRemarks.value = ''
+  handleFileList.value = []
+  handleRawFiles.value = []
   formRef.value?.resetFields()
   resetForm()
 }
 
+async function openDetailDialog(row: AlertItem) {
+  detailAlert.value = row
+  detailLifecycle.value = row.lifecycle?.length ? [...row.lifecycle] : []
+  detailVisible.value = true
+  detailLoading.value = true
+  try {
+    const detail = await fetchAlertDetail(String(row.id))
+    if (detail) {
+      detailAlert.value = detail
+      detailLifecycle.value = detail.lifecycle?.length
+        ? detail.lifecycle
+        : [
+            {
+              actionLabel: '产生告警',
+              status: detail.status,
+              operator: '系统',
+              result: detail.content,
+              operateTime: detail.createdAt,
+            },
+          ]
+    }
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+function onDetailClosed() {
+  detailAlert.value = null
+  detailLifecycle.value = []
+}
+
+function openHandleFromDetail() {
+  const row = detailAlert.value
+  if (!row) return
+  detailVisible.value = false
+  void openHandleDialog(row)
+}
+
 async function onSubmitHandle() {
   if (!currentAlert.value) return
   const valid = await formRef.value?.validate().catch(() => false)
   if (!valid) return
+  if (!handleRemarks.value.trim()) {
+    ElMessage.warning('请填写处理备注')
+    return
+  }
 
   saving.value = true
   try {
+    const f = form.value
+    // 先落库全字段人员信息(有人员ID时)
+    if (f.id) {
+      await savePerson({
+        ...f,
+        name: f.name.trim(),
+        idCard: f.idCard.trim(),
+        bankCard: f.bankCard.trim(),
+        source: f.source || '告警处理修改',
+      })
+    }
+    const uploaded: string[] = []
+    for (const file of handleRawFiles.value) {
+      const r = await uploadBizFile(file)
+      if (r?.url) uploaded.push(r.url)
+    }
     await handleAlert({
       id: currentAlert.value.id,
-      personName: form.personName.trim(),
-      idCard: form.idCard.trim(),
-      town: form.town.trim(),
-      village: form.village.trim(),
-      bankCard: form.bankCard.trim(),
-      bankName: form.bankName.trim(),
-      socialInsuranceCode: form.socialInsuranceCode.trim(),
-      heirName: form.heirName.trim(),
-      heirIdCard: form.heirIdCard.trim(),
-      heirBankCard: form.heirBankCard.trim(),
-      heirContact: form.heirContact.trim(),
+      personName: f.name.trim(),
+      idCard: f.idCard.trim(),
+      town: f.town.trim(),
+      village: f.village.trim(),
+      bankCard: f.bankCard.trim(),
+      bankName: (f.bankName || '').trim(),
+      socialInsuranceCode: (f.socialInsuranceCode || '').trim(),
+      heirName: (f.heirName || '').trim(),
+      heirIdCard: (f.heirIdNumber || '').trim(),
+      heirBankCard: (f.heirBankCard || '').trim(),
+      heirContact: (f.heirPhone || '').trim(),
+      result: handleRemarks.value.trim(),
+      remarks: handleRemarks.value.trim(),
+      fileUrls: uploaded.length ? uploaded.join(',') : undefined,
     })
     ElMessage.success('告警已处理并关闭(人员与继承人信息已保存,村/镇状态已同步)')
     dialogVisible.value = false
@@ -502,4 +565,15 @@ watch(
   font-size: 12px;
   line-height: 1.7;
 }
+
+.alerts-view__detail-desc {
+  margin-bottom: 14px;
+}
+
+.alerts-view__lifecycle-title {
+  margin: 4px 0 10px;
+  font-size: 13px;
+  font-weight: 600;
+  color: @primary-dark;
+}
 </style>

+ 521 - 98
src/views/approvals/ApprovalsView.vue

@@ -72,7 +72,22 @@
         <el-table-column prop="village" label="村居" width="90" />
         <el-table-column label="人数" width="64" align="center">
           <template #default="{ row }">
-            {{ (row as Approval).peopleCount != null ? (row as Approval).peopleCount : '—' }}
+            {{
+              isSpecialApproval(row as Approval)
+                ? (row as Approval).peopleCount ?? 1
+                : (row as Approval).peopleCount != null
+                  ? (row as Approval).peopleCount
+                  : '—'
+            }}
+          </template>
+        </el-table-column>
+        <el-table-column label="总发放金额" width="120" align="right">
+          <template #default="{ row }">
+            {{
+              (row as Approval).totalAmount != null
+                ? formatAmount((row as Approval).totalAmount)
+                : '—'
+            }}
           </template>
         </el-table-column>
         <el-table-column prop="applicant" label="申请人" width="140" show-overflow-tooltip />
@@ -135,7 +150,7 @@
     <el-dialog
       v-model="detailVisible"
       title="审批详情"
-      :width="isTownReview ? '960px' : '860px'"
+      :width="isTownReview ? '1100px' : '1040px'"
       destroy-on-close
       @closed="onDetailClosed"
     >
@@ -151,12 +166,83 @@
         <el-descriptions-item label="村居">{{ currentRow.village || '-' }}</el-descriptions-item>
         <el-descriptions-item label="申请人">{{ currentRow.applicant }}</el-descriptions-item>
         <el-descriptions-item label="提交时间">{{ currentRow.createdAt }}</el-descriptions-item>
+        <el-descriptions-item v-if="!isSpecialApproval(currentRow)" label="总发放金额" :span="2">
+          <strong>{{ formatAmount(detailAmountSummary.payable) }}</strong>
+          <span class="muted" style="margin-left: 8px">
+            <template v-if="isAdjustDetailBatch">(调标差额批:应发=补发 {{ formatAmount(detailAmountSummary.supplement) }})</template>
+            <template v-else>
+              (标准 {{ formatAmount(detailAmountSummary.monthly) }}
+              + 补发 {{ formatAmount(detailAmountSummary.supplement) }}
+              + 丧葬 {{ formatAmount(detailAmountSummary.funeral) }}
+              - 扣减 {{ formatAmount(detailAmountSummary.deduct) }})
+            </template>
+          </span>
+        </el-descriptions-item>
+        <el-descriptions-item v-else-if="currentRow.amount != null" label="涉及金额" :span="2">
+          {{ formatAmount(currentRow.amount) }}
+        </el-descriptions-item>
         <el-descriptions-item label="审批意见" :span="2">
           {{ currentRow.opinion || '-' }}
         </el-descriptions-item>
+        <template v-if="isSpecialApproval(currentRow)">
+          <el-descriptions-item v-if="currentRow.personName" label="人员">
+            {{ currentRow.personName }}
+          </el-descriptions-item>
+          <el-descriptions-item v-if="currentRow.deathDate" label="死亡日期">
+            {{ currentRow.deathDate }}
+          </el-descriptions-item>
+          <el-descriptions-item label="申请说明" :span="2">
+            {{ currentRow.reason || '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="证明材料" :span="2">
+            <FlowAttachments :file-urls="currentRow.fileUrls" show-empty />
+          </el-descriptions-item>
+        </template>
       </el-descriptions>
 
-      <div class="approvals-view__people">
+      <p v-if="isSpecialApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
+        特殊业务:通过后系统按业务类型回写人员状态(死亡登记通过后改为「死亡丧葬」并停止续发);驳回后退回业务受理补充材料。
+      </p>
+
+      <!-- 特殊业务:涉及人员(通常 1 人) -->
+      <div v-if="isSpecialApproval(currentRow)" class="approvals-view__people">
+        <div class="approvals-view__people-toolbar">
+          <div class="approvals-view__people-title">
+            涉及人员
+            <em>共 {{ detailPeople.length || (currentRow?.peopleCount ?? 0) }} 人</em>
+          </div>
+        </div>
+        <el-table
+          v-loading="peopleLoading"
+          :data="detailPeople"
+          border
+          stripe
+          size="small"
+          max-height="240"
+        >
+          <el-table-column prop="name" label="姓名" width="90" />
+          <el-table-column label="身份证" min-width="160">
+            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          </el-table-column>
+          <el-table-column prop="town" label="街镇" width="90" />
+          <el-table-column prop="village" label="村居" width="90" />
+          <el-table-column label="标准(元/月)" width="100" align="right">
+            <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+          </el-table-column>
+          <el-table-column label="人员状态" width="110" align="center">
+            <template #default="{ row }">
+              <StatusTag :status="row.status" />
+            </template>
+          </el-table-column>
+        </el-table>
+        <el-empty
+          v-if="!peopleLoading && !detailPeople.length"
+          description="未关联到在册人员,请核对申请说明与证明材料"
+          :image-size="64"
+        />
+      </div>
+
+      <div v-else class="approvals-view__people">
         <div class="approvals-view__people-toolbar">
           <div class="approvals-view__people-title">
             {{ isTownReview ? '村合作社提交名单' : '本村人员列表' }}
@@ -164,6 +250,9 @@
               ({{ currentRow.town }}{{ currentRow.village }} · 批次 {{ currentRow.batchNo }})
             </span>
             <em>共 {{ detailPeople.length }} 人</em>
+            <em class="approvals-view__people-amount">
+              ,总发放 {{ formatAmount(detailAmountSummary.payable) }} 元
+            </em>
             <em v-if="detailStatusFilter">,当前 {{ filteredDetailPeople.length }} 人</em>
             <em v-if="isTownReview && pendingQualifyCount > 0" class="approvals-view__people-warn">
               待核验 {{ pendingQualifyCount }} 人
@@ -193,24 +282,47 @@
           stripe
           size="small"
           max-height="320"
+          show-summary
+          :summary-method="detailTableSummary"
         >
           <el-table-column prop="name" label="姓名" width="80" />
-          <el-table-column label="身份证" min-width="150">
+          <el-table-column label="身份证" min-width="140">
             <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
           </el-table-column>
-          <el-table-column label="银行卡" min-width="160">
+          <el-table-column label="银行卡" min-width="140">
             <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
           </el-table-column>
-          <el-table-column prop="bankName" label="开户行" min-width="110" show-overflow-tooltip />
-          <el-table-column label="标准(元/月)" width="96" align="right">
-            <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+          <el-table-column v-if="!isAdjustDetailBatch" label="标准金额" width="100" align="right">
+            <template #default="{ row }">
+              {{ formatAmount((row as BatchMemberPerson).monthlyAmount ?? row.standard) }}
+            </template>
           </el-table-column>
-          <el-table-column v-if="isTownReview" label="退养资格" width="120" align="center">
+          <el-table-column label="补发金额" width="96" align="right">
+            <template #default="{ row }">
+              {{ formatAmount((row as BatchMemberPerson).supplementAmount) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="丧葬金额" width="96" align="right">
+            <template #default="{ row }">
+              {{ formatAmount((row as BatchMemberPerson).funeralAmount) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="扣减" width="88" align="right">
+            <template #default="{ row }">
+              {{ formatAmount((row as BatchMemberPerson).deductAmount) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="总计金额" width="104" align="right">
+            <template #default="{ row }">
+              <strong>{{ formatAmount((row as BatchMemberPerson).payableAmount) }}</strong>
+            </template>
+          </el-table-column>
+          <el-table-column v-if="isTownReview" label="退养资格" width="110" align="center">
             <template #default="{ row }">
               <el-tag
                 v-if="row.retireQualifyType"
                 size="small"
-                :type="row.retireQualifyType === '无土地有资格' ? 'warning' : 'success'"
+                :type="qualifyTagType(row.retireQualifyType)"
                 effect="plain"
               >
                 {{ row.retireQualifyType }}
@@ -223,7 +335,6 @@
               <StatusTag :status="row.status" />
             </template>
           </el-table-column>
-          <el-table-column v-if="!isTownReview" prop="source" label="来源" width="100" />
           <el-table-column
             v-if="isTownReview"
             label="操作"
@@ -242,7 +353,12 @@
               >
                 核验资格
               </el-button>
-              <span v-else-if="row.status === '资格已核验'" class="muted">已核验</span>
+              <span
+                v-else-if="row.retireQualifyType && row.retireQualifyType !== '无资格'"
+                class="muted"
+              >
+                已核验
+              </span>
               <span v-else-if="isPastQualified(row as Person)" class="muted">往期已核验</span>
               <span v-else class="muted">—</span>
             </template>
@@ -270,7 +386,13 @@
         <el-button type="danger" :loading="deciding" @click="onDecide(currentRow!, false)">
           驳回
         </el-button>
-        <el-button type="primary" :loading="deciding" @click="onDecide(currentRow!, true)">
+        <el-button
+          type="primary"
+          :loading="deciding"
+          :disabled="hasIneligibleMembers"
+          :title="hasIneligibleMembers ? '存在「无资格」人员,只能驳回由村处理' : undefined"
+          @click="onDecide(currentRow!, true)"
+        >
           通过
         </el-button>
       </template>
@@ -279,10 +401,67 @@
       </template>
     </el-dialog>
 
-    <!-- 驳回理由(必填) -->
-    <el-dialog v-model="rejectVisible" title="填写驳回理由" width="460px" destroy-on-close>
+    <!-- 通过确认(可附意见与附件) -->
+    <el-dialog
+      v-model="passVisible"
+      title="确认通过"
+      width="520px"
+      destroy-on-close
+      @closed="onPassDialogClosed"
+    >
+      <p class="approvals-view__reject-tip" style="color: var(--el-text-color-secondary)">
+        {{ passTip }}
+      </p>
+      <el-form label-width="90px" size="small">
+        <el-form-item label="审批意见">
+          <el-input
+            v-model="passOpinion"
+            type="textarea"
+            :rows="3"
+            maxlength="200"
+            show-word-limit
+            placeholder="可选,默认「同意」"
+            class="is-editable"
+          />
+        </el-form-item>
+        <el-form-item label="审批附件">
+          <el-upload
+            v-model:file-list="passFileList"
+            :auto-upload="false"
+            :limit="5"
+            accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
+            multiple
+            @change="syncPassFiles"
+            @remove="syncPassFiles"
+          >
+            <el-button type="primary">选择文件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">可上传审批依据图片或文件,将写入流程留痕</div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="passVisible = false">取消</el-button>
+        <el-button type="primary" :loading="deciding" @click="confirmPass">确认通过</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 驳回理由(必填,可附附件) -->
+    <el-dialog
+      v-model="rejectVisible"
+      title="填写驳回理由"
+      width="520px"
+      destroy-on-close
+      @closed="onRejectDialogClosed"
+    >
       <p class="approvals-view__reject-tip">
-        <template v-if="isTownReview">
+        <template v-if="isTownReview && ineligibleMembers.length">
+          本批有 {{ ineligibleMembers.length }} 人已认定「无资格」({{
+            ineligibleMembers.map((p) => p.name).join('、')
+          }})。驳回后将退回村合作社:请取消该人员或流程备注说明后重新提交。
+        </template>
+        <template v-else-if="isTownReview">
           驳回后批次将退回村合作社修改,请明确说明退回原因。
         </template>
         <template v-else-if="isLeaderReview">
@@ -292,7 +471,7 @@
           驳回后批次将退回修改,请明确说明退回原因。
         </template>
       </p>
-      <el-form label-width="80px" size="small">
+      <el-form label-width="90px" size="small">
         <el-form-item label="驳回理由" required>
           <el-input
             v-model="rejectOpinion"
@@ -304,6 +483,22 @@
             class="is-editable"
           />
         </el-form-item>
+        <el-form-item label="审批附件">
+          <el-upload
+            v-model:file-list="rejectFileList"
+            :auto-upload="false"
+            :limit="5"
+            accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
+            multiple
+            @change="syncRejectFiles"
+            @remove="syncRejectFiles"
+          >
+            <el-button type="primary">选择文件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">可上传驳回依据图片或文件,将写入流程留痕</div>
+            </template>
+          </el-upload>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="rejectVisible = false">取消</el-button>
@@ -312,9 +507,9 @@
     </el-dialog>
 
     <!-- 镇经发:核验退养资格 -->
-    <el-dialog v-model="qualifyVisible" title="核验退养资格" width="460px" destroy-on-close>
+    <el-dialog v-model="qualifyVisible" title="核验退养资格" width="520px" destroy-on-close>
       <p class="approvals-view__qualify-tip">
-        请确认该新增人员是否具备土地退养资格。核验后人员状态将更新为「资格已核验」
+        请确认该新增人员是否具备土地退养资格。有资格将写入「退养资格」字段;认定「无资格」后本批只能驳回,由村取消该人员或流程备注说明。人员状态仍显示业务底数状态(如待镇初审/正常在库)
       </p>
       <el-descriptions v-if="qualifyTarget" :column="1" border size="small" class="approvals-view__qualify-info">
         <el-descriptions-item label="姓名">{{ qualifyTarget.name }}</el-descriptions-item>
@@ -328,9 +523,13 @@
           <el-radio-group v-model="qualifyType">
             <el-radio value="有土地退养">有土地退养</el-radio>
             <el-radio value="无土地有资格">无土地有资格</el-radio>
+            <el-radio value="无资格">无资格</el-radio>
           </el-radio-group>
         </el-form-item>
       </el-form>
+      <p v-if="qualifyType === '无资格'" class="approvals-view__qualify-warn">
+        认定无资格后,本批次不可「通过」,只能「驳回」退回村合作社处理(取消该人员或补充说明后重提)。
+      </p>
       <template #footer>
         <el-button @click="qualifyVisible = false">取消</el-button>
         <el-button type="primary" :loading="reviewingId !== null" @click="confirmQualify">
@@ -343,23 +542,28 @@
 
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
+import { ElMessage } from 'element-plus'
+import type { UploadFile, UploadUserFile } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import {
   decideApproval,
   fetchApprovals,
   fetchBatchMemberPeople,
+  fetchSpecialBizPerson,
   reviewPersonQualify,
+  uploadBizFile,
 } from '@/api/biz'
 import { parseApprovalId } from '@/api/sjnmtybt/mappers'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
+import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
-import type { Approval, Person } from '@/types/biz'
+import type { Approval, BatchMemberPerson, Person } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
@@ -380,12 +584,47 @@ const deciding = ref(false)
 const selectedIds = ref<string[]>([])
 const detailVisible = ref(false)
 const currentRow = ref<Approval | null>(null)
-const detailPeople = ref<Person[]>([])
+const detailPeople = ref<BatchMemberPerson[]>([])
 const peopleLoading = ref(false)
+
+const isAdjustDetailBatch = computed(() => {
+  const b = currentRow.value
+  if (!b) return false
+  const remarks = String((b as any).remarks || (b as any).remark || '')
+  const no = String(b.batchNo || '')
+  return remarks.includes('调标') || remarks.includes('YEARLY_ADJUST') || /99(?:-|)/.test(no)
+})
+
+const detailAmountSummary = computed(() => sumBatchMemberAmounts(detailPeople.value))
+
+function detailTableSummary(param: {
+  columns: Array<{ label?: string; property?: string }>
+}) {
+  const sum = detailAmountSummary.value
+  return param.columns.map((col, index) => {
+    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)
+    return ''
+  })
+}
 const rejectVisible = ref(false)
 const rejectOpinion = ref('')
 const rejectTarget = ref<Approval | null>(null)
 const rejectBatchMode = ref(false)
+const rejectFileList = ref<UploadUserFile[]>([])
+const rejectRawFiles = ref<File[]>([])
+const passVisible = ref(false)
+const passOpinion = ref('同意')
+const passTip = ref('')
+const passTarget = ref<Approval | null>(null)
+const passBatchMode = ref(false)
+const passFileList = ref<UploadUserFile[]>([])
+const passRawFiles = ref<File[]>([])
 const batchDrawerVisible = ref(false)
 const batchDrawerId = ref('')
 
@@ -395,6 +634,49 @@ function batchIdOfApproval(row: Approval): string {
   return ''
 }
 
+function isSpecialApproval(row: Approval | null | undefined): boolean {
+  if (!row) return false
+  const parsed = parseApprovalId(String(row.id || ''))
+  return parsed?.kind === 'special'
+}
+
+function syncRejectFiles(_file: UploadFile, fileList: UploadFile[]) {
+  rejectFileList.value = fileList
+  rejectRawFiles.value = fileList.map((f) => f.raw).filter((f): f is File => !!f)
+}
+
+function syncPassFiles(_file: UploadFile, fileList: UploadFile[]) {
+  passFileList.value = fileList
+  passRawFiles.value = fileList.map((f) => f.raw).filter((f): f is File => !!f)
+}
+
+function onRejectDialogClosed() {
+  rejectOpinion.value = ''
+  rejectFileList.value = []
+  rejectRawFiles.value = []
+  rejectTarget.value = null
+  rejectBatchMode.value = false
+}
+
+function onPassDialogClosed() {
+  passOpinion.value = '同意'
+  passTip.value = ''
+  passFileList.value = []
+  passRawFiles.value = []
+  passTarget.value = null
+  passBatchMode.value = false
+}
+
+async function uploadApproveFiles(files: File[]): Promise<string | undefined> {
+  if (!files.length) return undefined
+  const uploaded: string[] = []
+  for (const f of files) {
+    const r = await uploadBizFile(f)
+    if (r?.url) uploaded.push(r.url)
+  }
+  return uploaded.length ? uploaded.join(',') : undefined
+}
+
 function openBatchFlow(row: Approval) {
   const id = batchIdOfApproval(row)
   if (!id) {
@@ -407,9 +689,15 @@ function openBatchFlow(row: Approval) {
 
 const qualifyVisible = ref(false)
 const qualifyTarget = ref<Person | null>(null)
-const qualifyType = ref<'有土地退养' | '无土地有资格'>('有土地退养')
+const qualifyType = ref<'有土地退养' | '无土地有资格' | '无资格'>('有土地退养')
 const reviewingId = ref<number | string | null>(null)
 
+function qualifyTagType(t?: string): 'success' | 'warning' | 'danger' | 'info' {
+  if (t === '无资格') return 'danger'
+  if (t === '无土地有资格') return 'warning'
+  return 'success'
+}
+
 const filteredList = computed(() => {
   if (!isAdmin.value) return list.value
   return list.value.filter((row) => matchTownVillage(row))
@@ -470,29 +758,36 @@ const pendingQualifyCount = computed(
   () => detailPeople.value.filter((p) => needsQualifyReview(p)).length,
 )
 
+/** 已认定「无资格」的人员:批次只能驳回 */
+const ineligibleMembers = computed(() =>
+  detailPeople.value.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格'),
+)
+const hasIneligibleMembers = computed(() => ineligibleMembers.value.length > 0)
+
 function onSelectionChange(rows: Approval[]) {
   selectedIds.value = rows.map((r) => r.id)
 }
 
 /** 本批次新增且尚未完成资格核验的状态 */
-const NEW_PENDING_STATUSES = ['草稿', '人员新增录入', '退回修改', '待人工确认']
+const NEW_PENDING_STATUSES = ['草稿', '人员新增录入', '退回修改', '待人工确认', '待镇初审']
 
-/** 是否本批次新增、仍待资格核验 */
+function hasQualifyResult(row: Person) {
+  return Boolean(row.retireQualifyType)
+}
+
+/** 是否本批次新增、仍待资格核验(以退养资格字段为准,不伪造人员状态文案) */
 function needsQualifyReview(row: Person) {
-  if (row.status === '资格已核验') return false
+  if (hasQualifyResult(row)) return false
+  if (row.status === '无资格') return false
   return NEW_PENDING_STATUSES.includes(row.status) || row.status.includes('待审')
 }
 
 /** 往期已通过资格审核(历史在册等),无需再次核验 */
 function isPastQualified(row: Person) {
-  if (row.status === '资格已核验') return false
+  if (row.status === '无资格' || row.retireQualifyType === '无资格') return false
+  if (hasQualifyResult(row)) return false
   if (needsQualifyReview(row)) return false
-  // 正常在库等历史状态,或已带有资格认定结果
-  return (
-    row.status === '正常在库' ||
-    Boolean(row.retireQualifyType) ||
-    /通过|在库|暂停|停止/.test(row.status)
-  )
+  return row.status === '正常在库' || /通过|在库|暂停|停止/.test(row.status)
 }
 
 /** 待审批名单中的新增人员可做资格核验;往期已通过的不可再审 */
@@ -504,7 +799,9 @@ function canReviewQualify(row: Person) {
 
 function openQualifyDialog(row: Person) {
   qualifyTarget.value = row
-  qualifyType.value = row.retireQualifyType === '无土地有资格' ? '无土地有资格' : '有土地退养'
+  if (row.retireQualifyType === '无资格') qualifyType.value = '无资格'
+  else if (row.retireQualifyType === '无土地有资格') qualifyType.value = '无土地有资格'
+  else qualifyType.value = '有土地退养'
   qualifyVisible.value = true
 }
 
@@ -519,35 +816,90 @@ async function confirmQualify() {
     // 同步刷新详情列表中的该行
     const idx = detailPeople.value.findIndex((p) => p.id === qualifyTarget.value!.id)
     if (idx >= 0) {
+      const prev = detailPeople.value[idx]
+      let remark = prev.remark
+      if (qualifyType.value === '无资格') {
+        remark = [prev.remark, '无资格;不具备土地退养补助资格'].filter(Boolean).join(';')
+      } else if (qualifyType.value === '无土地有资格' && !prev.remark?.includes('无土地')) {
+        remark = [prev.remark, '无承包地,按政策认定具备退养补助资格'].filter(Boolean).join(';')
+      }
       detailPeople.value[idx] = {
-        ...detailPeople.value[idx],
-        status: '资格已核验',
+        ...prev,
+        // 人员状态跟业务底数;有资格不改写状态文案,无资格展示「无资格」
+        status: qualifyType.value === '无资格' ? '无资格' : prev.status,
         retireQualifyType: qualifyType.value,
-        remark:
-          qualifyType.value === '无土地有资格'
-            ? detailPeople.value[idx].remark?.includes('无土地')
-              ? detailPeople.value[idx].remark
-              : [detailPeople.value[idx].remark, '无承包地,按政策认定具备退养补助资格']
-                  .filter(Boolean)
-                  .join(';')
-            : detailPeople.value[idx].remark,
+        remark,
       }
     }
-    ElMessage.success(`已核验:${qualifyTarget.value.name}(${qualifyType.value})`)
+    if (qualifyType.value === '无资格') {
+      ElMessage.warning(
+        `已认定「无资格」:${qualifyTarget.value.name}。本批只能驳回,由村取消该人员或补充说明。`,
+      )
+    } else {
+      ElMessage.success(`已核验:${qualifyTarget.value.name}(${qualifyType.value})`)
+    }
     qualifyVisible.value = false
   } finally {
     reviewingId.value = null
   }
 }
 
-/** 打开详情:按批次发放明细加载申报名单(不是全村人员库) */
+/** 打开详情:批次拉发放明细;特殊业务拉关联人员 */
 async function openDetail(row: Approval) {
-  currentRow.value = row
+  currentRow.value = { ...row }
   detailVisible.value = true
   detailPeople.value = []
   detailStatusFilter.value = ''
   peopleLoading.value = true
   try {
+    if (isSpecialApproval(row)) {
+      let person = row.personnelId
+        ? await fetchSpecialBizPerson(row.personnelId)
+        : null
+      if (!person && row.personName) {
+        // 兜底:列表已有姓名时先展示一行,避免详情空白
+        person = {
+          id: row.personnelId || row.id,
+          batchNo: '',
+          name: row.personName,
+          idCard: row.idCard || '',
+          town: row.town,
+          village: row.village && row.village !== '—' ? row.village : '',
+          bankCard: '',
+          bankName: '',
+          standard: row.amount || 0,
+          startMonth: '',
+          status: '—',
+          source: '特殊业务申请',
+          insuranceStatus: '',
+        }
+      }
+      if (person) {
+        const amt = Number(row.amount ?? person.standard ?? 0) || 0
+        detailPeople.value = [
+          {
+            ...person,
+            batchNo: person.batchNo || '',
+            monthlyAmount: amt,
+            supplementAmount: 0,
+            funeralAmount: row.bizType?.includes('丧葬') || row.bizType?.includes('死亡') ? amt : 0,
+            deductAmount: 0,
+            payableAmount: amt,
+            standard: amt || person.standard,
+          },
+        ]
+        currentRow.value = {
+          ...currentRow.value!,
+          village: person.village || currentRow.value?.village,
+          town: person.town || currentRow.value?.town,
+          personName: person.name || currentRow.value?.personName,
+          idCard: person.idCard || currentRow.value?.idCard,
+          peopleCount: 1,
+          totalAmount: amt,
+        }
+      }
+      return
+    }
     const batchId = row.batchId || batchIdOfApproval(row)
     if (!batchId) {
       detailPeople.value = []
@@ -560,6 +912,15 @@ async function openDetail(row: Approval) {
       // 已提交镇审:以当时发放明细为准,不再做默认剔除
       pruneDefaults: false,
     })
+    // 以发放明细实时汇总覆盖列表上的批次头金额(含补发/丧葬/扣减)
+    const sum = sumBatchMemberAmounts(detailPeople.value)
+    if (currentRow.value) {
+      currentRow.value = {
+        ...currentRow.value,
+        peopleCount: sum.count || currentRow.value.peopleCount,
+        totalAmount: sum.payable,
+      }
+    }
     resetDetailPeoplePage()
   } finally {
     peopleLoading.value = false
@@ -572,19 +933,27 @@ function onDetailClosed() {
   detailStatusFilter.value = ''
 }
 
-async function doDecide(row: Approval, pass: boolean, opinion?: string) {
+async function doDecide(
+  row: Approval,
+  pass: boolean,
+  opinion?: string,
+  fileUrls?: string,
+) {
   deciding.value = true
   try {
-    await decideApproval({ id: row.id, pass, opinion })
+    await decideApproval({ id: row.id, pass, opinion, fileUrls })
     const isTown = row.node.includes('镇经发')
     const isMonthly = row.bizType.includes('月') || row.bizType.includes('清单')
     if (pass) {
+      const isDeath = row.bizType.includes('死亡')
       ElMessage.success(
         isTown
           ? '已通过,批次状态已更新为「居保状态匹配」'
           : isMonthly
             ? '已通过,批次进入「已审核」,可由业务部门出盘'
-            : '已通过',
+            : isDeath
+              ? '已通过,人员状态已更新为「死亡丧葬」'
+              : '已通过',
       )
     } else {
       ElMessage.success(
@@ -604,61 +973,111 @@ async function doDecide(row: Approval, pass: boolean, opinion?: string) {
 
 async function onDecide(row: Approval, pass: boolean) {
   if (pass) {
-    // 镇经发初审:确认后修改批次状态
+    if (
+      (isTownReview.value || row.node.includes('镇经发')) &&
+      detailVisible.value &&
+      currentRow.value?.id === row.id &&
+      hasIneligibleMembers.value
+    ) {
+      ElMessage.warning(
+        `本批有 ${ineligibleMembers.value.length} 人已认定「无资格」,不能通过;请驳回由村取消该人员或补充说明。`,
+      )
+      return
+    }
+    const unchecked =
+      (isTownReview.value || row.node.includes('镇经发')) &&
+      detailVisible.value &&
+      currentRow.value?.id === row.id
+        ? pendingQualifyCount.value
+        : 0
+    passTarget.value = row
+    passBatchMode.value = false
+    passOpinion.value = '同意'
+    passFileList.value = []
+    passRawFiles.value = []
     if (isTownReview.value || row.node.includes('镇经发')) {
-      const unchecked =
-        detailVisible.value && currentRow.value?.id === row.id
-          ? pendingQualifyCount.value
-          : 0
-      const tip =
+      passTip.value =
         unchecked > 0
           ? `仍有 ${unchecked} 人未完成资格核验。确认仍要通过「${row.title}」?通过后该村批次将进入「居保状态匹配」。`
           : `确认通过「${row.title}」?通过后该村批次将进入「居保状态匹配」。`
-      try {
-        await ElMessageBox.confirm(tip, '确认通过', {
-          type: unchecked > 0 ? 'warning' : 'success',
-          confirmButtonText: '确认通过',
-          cancelButtonText: '取消',
-        })
-      } catch {
-        return
-      }
+    } else if (isSpecialApproval(row)) {
+      passTip.value = `确认通过「${row.title}」?特殊业务通过后将按类型回写人员状态(死亡登记通过后变为「死亡丧葬」)。`
+    } else {
+      passTip.value = `确认通过「${row.title}」?通过后对应批次状态将更新。`
     }
-    await doDecide(row, true, '同意')
+    passVisible.value = true
     return
   }
-  // 驳回:必须填写理由
   rejectTarget.value = row
   rejectBatchMode.value = false
-  rejectOpinion.value = ''
+  rejectFileList.value = []
+  rejectRawFiles.value = []
+  rejectOpinion.value = hasIneligibleMembers.value
+    ? `存在无资格人员(${ineligibleMembers.value.map((p) => p.name).join('、')}),退回村合作社取消该人员或补充说明后重提。`
+    : ''
   rejectVisible.value = true
 }
 
+async function confirmPass() {
+  const opinion = passOpinion.value.trim() || '同意'
+  deciding.value = true
+  try {
+    const fileUrls = await uploadApproveFiles(passRawFiles.value)
+    if (passBatchMode.value) {
+      for (const id of selectedIds.value) {
+        await decideApproval({ id, pass: true, opinion, fileUrls })
+      }
+      ElMessage.success('批量通过成功,批次状态已更新')
+      selectedIds.value = []
+      passVisible.value = false
+      detailVisible.value = false
+      await loadApprovals()
+      return
+    }
+    if (passTarget.value) {
+      // 避免与 doDecide 重复切换 loading
+      deciding.value = false
+      await doDecide(passTarget.value, true, opinion, fileUrls)
+      passVisible.value = false
+    }
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '上传或审批失败')
+    deciding.value = false
+  } finally {
+    if (passBatchMode.value) deciding.value = false
+  }
+}
+
 async function confirmReject() {
   const reason = rejectOpinion.value.trim()
   if (!reason) {
     ElMessage.warning('请填写驳回理由')
     return
   }
-  if (rejectBatchMode.value) {
-    deciding.value = true
-    try {
+  deciding.value = true
+  try {
+    const fileUrls = await uploadApproveFiles(rejectRawFiles.value)
+    if (rejectBatchMode.value) {
       for (const id of selectedIds.value) {
-        await decideApproval({ id, pass: false, opinion: reason })
+        await decideApproval({ id, pass: false, opinion: reason, fileUrls })
       }
       ElMessage.success('批量驳回成功,相关批次已退回修改')
       selectedIds.value = []
       rejectVisible.value = false
       detailVisible.value = false
       await loadApprovals()
-    } finally {
+      return
+    }
+    if (rejectTarget.value) {
       deciding.value = false
+      await doDecide(rejectTarget.value, false, reason, fileUrls)
+      rejectVisible.value = false
     }
-    return
-  }
-  if (rejectTarget.value) {
-    await doDecide(rejectTarget.value, false, reason)
-    rejectVisible.value = false
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '上传或驳回失败')
+    deciding.value = false
+  } finally {
+    if (rejectBatchMode.value) deciding.value = false
   }
 }
 
@@ -667,29 +1086,18 @@ async function onBatchDecide(pass: boolean) {
     rejectBatchMode.value = true
     rejectTarget.value = null
     rejectOpinion.value = ''
+    rejectFileList.value = []
+    rejectRawFiles.value = []
     rejectVisible.value = true
     return
   }
-  try {
-    await ElMessageBox.confirm(
-      `确认批量通过选中的 ${selectedIds.value.length} 条审批?通过后对应批次状态将更新。`,
-      '批量通过',
-      { type: 'success', confirmButtonText: '确认通过', cancelButtonText: '取消' },
-    )
-  } catch {
-    return
-  }
-  deciding.value = true
-  try {
-    for (const id of selectedIds.value) {
-      await decideApproval({ id, pass: true, opinion: '同意' })
-    }
-    ElMessage.success('批量通过成功,批次状态已更新')
-    selectedIds.value = []
-    await loadApprovals()
-  } finally {
-    deciding.value = false
-  }
+  passBatchMode.value = true
+  passTarget.value = null
+  passOpinion.value = '同意'
+  passTip.value = `确认批量通过选中的 ${selectedIds.value.length} 条审批?通过后对应批次状态将更新。`
+  passFileList.value = []
+  passRawFiles.value = []
+  passVisible.value = true
 }
 
 async function loadApprovals() {
@@ -771,6 +1179,11 @@ watch(
   &-warn {
     color: #c45c26 !important;
   }
+
+  &-amount {
+    color: @primary-dark !important;
+    font-weight: 600 !important;
+  }
 }
 
 .approvals-view__status-filter {
@@ -798,6 +1211,16 @@ watch(
   margin-top: 4px;
 }
 
+.approvals-view__qualify-warn {
+  margin: 0 0 4px;
+  padding: 8px 10px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #a63d2c;
+  background: rgba(196, 92, 38, 0.08);
+  border-radius: 4px;
+}
+
 .muted {
   color: @text-secondary;
   font-size: 12px;

+ 727 - 121
src/views/base/BaseInfoView.vue

@@ -6,6 +6,19 @@
         <div class="block-title base-info-view__title">人员新增录入</div>
         <div class="base-info-view__toolbar">
           <el-button type="primary" size="small" @click="openPersonDialog">单人信息录入</el-button>
+          <el-button size="small" :loading="templateDownloading" @click="onDownloadTemplate">
+            下载导入模板
+          </el-button>
+          <el-upload
+            class="base-info-view__import-upload"
+            :show-file-list="false"
+            :auto-upload="false"
+            accept=".xlsx,.xls,.csv"
+            :disabled="importing"
+            :on-change="onImportFileChange"
+          >
+            <el-button size="small" type="success" :loading="importing">Excel 导入</el-button>
+          </el-upload>
         </div>
       </div>
       <p class="base-info-view__desc">
@@ -195,8 +208,76 @@
         </template>
       </el-dialog>
 
-      <!-- 本村发放人历史记录 -->
-      <PagePanel :title="isAdmin ? '发放人历史记录' : '本村发放人历史记录'" class="base-info-view__panel">
+      <!-- 本村人员底数(服务端分页) -->
+      <PagePanel :title="isAdmin ? '人员列表' : '本村人员列表'" class="base-info-view__panel">
+        <el-form :inline="true" size="small" class="base-info-view__roster-filter">
+          <el-form-item label="姓名">
+            <el-input
+              v-model="rosterFilter.name"
+              clearable
+              placeholder="模糊搜索"
+              style="width: 110px"
+              class="is-editable"
+              @keyup.enter="applyRosterFilter"
+            />
+          </el-form-item>
+          <el-form-item label="身份证">
+            <el-input
+              v-model="rosterFilter.idCard"
+              clearable
+              placeholder="后几位亦可"
+              style="width: 140px"
+              class="is-editable"
+              @keyup.enter="applyRosterFilter"
+            />
+          </el-form-item>
+          <el-form-item label="手机号">
+            <el-input
+              v-model="rosterFilter.phone"
+              clearable
+              placeholder="模糊"
+              style="width: 120px"
+              class="is-editable"
+              @keyup.enter="applyRosterFilter"
+            />
+          </el-form-item>
+          <el-form-item label="业务状态">
+            <el-select
+              v-model="rosterFilter.bizStatus"
+              clearable
+              placeholder="全部"
+              style="width: 130px"
+              class="is-editable"
+            >
+              <el-option
+                v-for="s in rosterStatusOptions"
+                :key="s.value"
+                :label="s.label"
+                :value="s.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="来源">
+            <el-select
+              v-model="rosterFilter.source"
+              clearable
+              filterable
+              allow-create
+              placeholder="全部"
+              style="width: 120px"
+              class="is-editable"
+            >
+              <el-option v-for="s in rosterSourceOptions" :key="s" :label="s" :value="s" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="applyRosterFilter">查询</el-button>
+            <el-button @click="resetRosterFilter">重置</el-button>
+          </el-form-item>
+        </el-form>
+        <p class="base-info-view__history-tip">
+          展示本村在册人员底数(非发放明细);月补标准为街镇当前有效标准。可查看详情、告警与历史发放。
+        </p>
         <div class="base-info-view__table-wrap">
           <el-table
             v-loading="peopleLoading"
@@ -206,26 +287,43 @@
             size="small"
             height="100%"
           >
-            <el-table-column prop="batchNo" label="批次号" width="100" align="center" />
             <el-table-column prop="name" label="姓名" width="80" />
-            <el-table-column label="身份证" min-width="160">
+            <el-table-column label="身份证" min-width="148">
               <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
             </el-table-column>
+            <el-table-column label="手机号" width="118">
+              <template #default="{ row }">{{ row.phoneNumber || '—' }}</template>
+            </el-table-column>
             <el-table-column prop="town" label="街镇" width="90" />
             <el-table-column prop="village" label="村居" width="90" />
-            <el-table-column label="银行卡" min-width="180">
+            <el-table-column label="银行卡" min-width="140">
               <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
             </el-table-column>
-            <el-table-column prop="bankName" label="开户行" min-width="140" show-overflow-tooltip />
-            <el-table-column label="标准(元/月)" width="100" align="right">
+            <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">
               <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
             </el-table-column>
-            <el-table-column label="状态" width="110" align="center">
+            <el-table-column label="状态" width="100" align="center">
               <template #default="{ row }">
                 <StatusTag :status="row.status" />
               </template>
             </el-table-column>
-            <el-table-column prop="source" label="来源" width="110" />
+            <el-table-column prop="source" label="来源" width="100" show-overflow-tooltip />
+            <el-table-column label="操作" width="220" align="center" fixed="right">
+              <template #default="{ row }">
+                <el-button link type="primary" size="small" @click="openPersonDetail(row as Person)">
+                  详情
+                </el-button>
+                <el-button link type="warning" size="small" @click="openPersonAlerts(row as Person)">
+                  告警
+                </el-button>
+                <el-button link type="success" size="small" @click="openPersonHistory(row as Person)">
+                  历史发放
+                </el-button>
+              </template>
+            </el-table-column>
           </el-table>
         </div>
         <TablePagination
@@ -237,76 +335,252 @@
       </PagePanel>
     </div>
 
-    <!-- 人员录入弹窗 -->
-    <el-dialog v-model="personDialogVisible" title="单人信息录入" width="640px" destroy-on-close>
+    <!-- 人员详细信息(人员表全字段) -->
+    <el-dialog
+      v-model="personDetailVisible"
+      :title="personDetail ? `详细信息 · ${personDetail.name}` : '详细信息'"
+      width="920px"
+      destroy-on-close
+      @closed="personDetail = null"
+    >
+      <div v-loading="personDetailLoading">
+        <PersonnelDetailDescriptions v-if="personDetail" :person="personDetail" />
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="personDetailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 人员告警处理记录 -->
+    <el-dialog
+      v-model="personAlertVisible"
+      :title="personAlertTarget ? `告警记录 · ${personAlertTarget.name}` : '告警记录'"
+      width="900px"
+      destroy-on-close
+      @closed="onPersonAlertClosed"
+    >
+      <el-table v-loading="personAlertLoading" :data="personAlerts" border stripe size="small" max-height="420">
+        <el-table-column prop="createdAt" label="产生时间" width="150" />
+        <el-table-column prop="batchNo" label="批次号" width="96" align="center" />
+        <el-table-column prop="type" label="类型" width="110" show-overflow-tooltip />
+        <el-table-column label="等级" width="72" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="row.level" mode="level" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="content" label="内容" min-width="160" show-overflow-tooltip />
+        <el-table-column label="处理状态" width="90" align="center">
+          <template #default="{ row }">
+            <StatusTag :status="row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="result" label="处理结果" min-width="120" show-overflow-tooltip />
+        <el-table-column label="操作" width="88" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" size="small" @click="openAlertLifecycle(row as AlertItem)">
+              处理流程
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-empty
+        v-if="!personAlertLoading && !personAlerts.length"
+        description="暂无告警/处理记录"
+        :image-size="64"
+      />
+      <template #footer>
+        <el-button type="primary" @click="personAlertVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 单条告警处理流程 -->
+    <el-dialog
+      v-model="alertLifecycleVisible"
+      :title="alertLifecycleTitle"
+      width="640px"
+      append-to-body
+      destroy-on-close
+      @closed="onAlertLifecycleClosed"
+    >
+      <AlertLifecycleTimeline :lifecycle="alertLifecycleItems" :loading="alertLifecycleLoading" />
+      <template #footer>
+        <el-button type="primary" @click="alertLifecycleVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 历史发放记录 -->
+    <el-dialog
+      v-model="personHistoryVisible"
+      :title="personHistoryTarget ? `历史发放 · ${personHistoryTarget.name}` : '历史发放'"
+      width="960px"
+      destroy-on-close
+      @closed="onPersonHistoryClosed"
+    >
+      <el-table
+        v-loading="personHistoryLoading"
+        :data="personHistoryRows"
+        border
+        stripe
+        size="small"
+        max-height="420"
+      >
+        <el-table-column prop="batchNo" label="批次号" width="100" align="center" />
+        <el-table-column label="标准金额" width="100" align="right">
+          <template #default="{ row }">
+            {{
+              isAdjustPayRow(row as BatchMemberPerson)
+                ? '—'
+                : formatAmount(row.monthlyAmount ?? row.standard)
+            }}
+          </template>
+        </el-table-column>
+        <el-table-column label="补发金额" width="96" align="right">
+          <template #default="{ row }">{{ formatAmount(row.supplementAmount) }}</template>
+        </el-table-column>
+        <el-table-column label="丧葬金额" width="96" align="right">
+          <template #default="{ row }">{{ formatAmount(row.funeralAmount) }}</template>
+        </el-table-column>
+        <el-table-column label="扣减" width="88" align="right">
+          <template #default="{ row }">{{ formatAmount(row.deductAmount) }}</template>
+        </el-table-column>
+        <el-table-column label="应发合计" width="104" align="right">
+          <template #default="{ row }">
+            <strong>{{ formatAmount(row.payableAmount) }}</strong>
+          </template>
+        </el-table-column>
+        <el-table-column label="实发" width="96" align="right">
+          <template #default="{ row }">
+            {{ row.actualAmount == null ? '—' : formatAmount(row.actualAmount) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="回盘结果" width="100" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.returnResult || '—' }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="72" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              link
+              type="primary"
+              size="small"
+              @click="openPersonPay(row as BatchMemberPerson)"
+            >
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-empty
+        v-if="!personHistoryLoading && !personHistoryRows.length"
+        description="暂无历史发放记录"
+        :image-size="64"
+      />
+      <template #footer>
+        <el-button type="primary" @click="personHistoryVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 单笔发放金额详情 -->
+    <el-dialog
+      v-model="personPayVisible"
+      :title="personPayTitle"
+      width="520px"
+      append-to-body
+      destroy-on-close
+    >
+      <template v-if="currentPersonPay">
+        <el-descriptions :column="1" border size="small">
+          <el-descriptions-item label="批次号">
+            {{ currentPersonPay.batchNo || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
+          <el-descriptions-item label="身份证">
+            {{ maskIdCard(currentPersonPay.idCard) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="街镇/村居">
+            {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
+          </el-descriptions-item>
+          <el-descriptions-item label="银行卡">
+            {{ maskBankCard(currentPersonPay.bankCard) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="开户行">
+            {{ currentPersonPay.bankName || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item
+            v-if="!isAdjustPayRow(currentPersonPay)"
+            label="月补标准(元/月)"
+          >
+            {{ formatAmount(currentPersonPay.monthlyAmount ?? currentPersonPay.standard) }}
+          </el-descriptions-item>
+          <el-descriptions-item v-if="!isAdjustPayRow(currentPersonPay)" label="一次性代发">
+            {{ formatAmount(currentPersonPay.oneTimeAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="补发金额">
+            {{ formatAmount(currentPersonPay.supplementAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item v-if="!isAdjustPayRow(currentPersonPay)" label="丧葬费">
+            {{ formatAmount(currentPersonPay.funeralAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item v-if="!isAdjustPayRow(currentPersonPay)" label="扣减金额">
+            {{ formatAmount(currentPersonPay.deductAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="应发合计">
+            <strong>{{ formatAmount(currentPersonPay.payableAmount) }}</strong>
+            <div class="base-info-view__pay-tip">
+              <template v-if="isAdjustPayRow(currentPersonPay)">
+                本批为年调标差额补发,不展示月标;应发合计=补发差额。
+              </template>
+              <template v-else>
+                应发=月补标准+一次性代发+补发+丧葬费−扣减(以后台重算为准)。
+              </template>
+            </div>
+          </el-descriptions-item>
+          <el-descriptions-item label="实发金额">
+            {{
+              currentPersonPay.actualAmount == null
+                ? '未回盘'
+                : formatAmount(currentPersonPay.actualAmount)
+            }}
+          </el-descriptions-item>
+          <el-descriptions-item label="回盘结果">
+            {{ currentPersonPay.returnResult || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="人员状态">
+            <StatusTag :status="currentPersonPay.status" />
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">
+            {{ currentPersonPay.remark || '—' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-dialog>
+
+    <!-- 人员录入弹窗(人员表全字段) -->
+    <el-dialog v-model="personDialogVisible" title="单人信息录入" width="920px" destroy-on-close>
       <p class="base-info-view__dialog-tip">
         <template v-if="isAdmin">
-          请确认已选择街镇与村居({{ villageScopeLabel }})。姓名、身份证号、银行卡号为必填;开户行、社会保险登记码、备注为选填。
+          请确认已选择街镇与村居({{ villageScopeLabel }})。姓名、身份证号、手机号、银行卡号为必填;月补标准由街镇标准自动带出,无需手填。灰色项为系统只读
         </template>
         <template v-else>
-          仅可录入本村({{ villageScopeLabel }})人员。姓名、身份证号、银行卡号为必填;开户行、社会保险登记码、备注为选填。社会保险登记码用于后续居保状态匹配,建议一并填写。
+          仅可录入本村({{ villageScopeLabel }})人员。姓名、身份证号、手机号、银行卡号为必填;收款人/继承人等选填,有值时证件与手机将校验格式
         </template>
       </p>
-      <el-form :model="personForm" label-width="120px" size="small">
-        <el-row :gutter="12">
-          <el-col :span="12">
-            <el-form-item label="所属村居">
-              <el-input :model-value="villageScopeLabel" disabled />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="姓名" required>
-              <el-input v-model="personForm.name" class="is-editable" placeholder="请输入姓名" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="身份证号" required>
-              <el-input
-                v-model="personForm.idCard"
-                class="is-editable"
-                placeholder="请输入18位身份证号"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="银行卡号" required>
-              <el-input
-                v-model="personForm.bankCard"
-                class="is-editable"
-                placeholder="请输入银行卡号"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="开户行">
-              <el-input
-                v-model="personForm.bankName"
-                class="is-editable"
-                placeholder="选填,如工商银行叶榭支行"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="社会保险登记码">
-              <el-input
-                v-model="personForm.socialInsuranceCode"
-                class="is-editable"
-                placeholder="选填,建议填写以便居保匹配"
-              />
-            </el-form-item>
-          </el-col>
-          <el-col :span="24">
-            <el-form-item label="备注">
-              <el-input
-                v-model="personForm.remark"
-                type="textarea"
-                :rows="2"
-                class="is-editable"
-                placeholder="选填,可注明材料或特殊说明"
-              />
-            </el-form-item>
-          </el-col>
-        </el-row>
+      <el-form
+        ref="personFormRef"
+        :model="personForm"
+        :rules="personFormRules"
+        label-width="140px"
+        size="small"
+      >
+        <PersonnelFormFields
+          :model="personForm"
+          mode="create"
+          :town-options="townOptions"
+          :village-options="villageOptions"
+          :town-disabled="true"
+          :village-disabled="true"
+          :required-keys="['name', 'idCard', 'phoneNumber', 'bankCard']"
+          @update:model="onPersonFormUpdate"
+        />
       </el-form>
       <template #footer>
         <el-button @click="personDialogVisible = false">取消</el-button>
@@ -319,30 +593,55 @@
 <script setup lang="ts">
 import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import type { TableInstance } from 'element-plus'
+import type { FormInstance, FormRules, TableInstance, UploadFile } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
+import AlertLifecycleTimeline from '@/components/biz/AlertLifecycleTimeline.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
+import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
+  downloadPersonnelImportTemplate,
+  ensureVillageMonthBatch,
+  fetchAlertDetail,
   fetchBatchMemberIds,
   fetchPeople,
+  fetchPeoplePage,
+  fetchPersonDetail,
+  fetchPersonnelAlerts,
+  fetchPersonnelPaymentHistory,
   fetchVillageBatches,
+  importPersonnelExcel,
   savePerson,
   submitVillageBatch,
   updateBatchMembers,
 } from '@/api/biz'
+import { emptyPersonForm } from '@/constants/personnelFields'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
-import type { Person, VillageBatch } from '@/types/biz'
+import {
+  isValidIdCard,
+  isValidIdCardOptional,
+  isValidPhone,
+  isValidPhoneOptional,
+} from '@/utils/validators'
+import type {
+  AlertItem,
+  AlertLifecycleItem,
+  BatchMemberPerson,
+  Person,
+  VillageBatch,
+} from '@/types/biz'
 
 /** 本村月度批次默认每页条数 */
 const BATCH_PAGE_SIZE = 5
 const pageSizes = [5, 10, 20]
-/** 本村发放人历史记录默认每页条数(不含 5) */
+/** 本村人员列表默认每页条数 */
 const PEOPLE_PAGE_SIZE = 10
 const peoplePageSizes = [10, 20]
 
@@ -359,10 +658,212 @@ const {
 } = useTownVillageOptions()
 
 const batches = ref<VillageBatch[]>([])
-const people = ref<Person[]>([])
 const batchLoading = ref(false)
-const peopleLoading = ref(false)
+const personPayVisible = ref(false)
+const currentPersonPay = ref<BatchMemberPerson | null>(null)
+const personPayTitle = computed(() =>
+  currentPersonPay.value
+    ? `${currentPersonPay.value.name} · 批次 ${currentPersonPay.value.batchNo || '—'} 发放详情`
+    : '发放详情',
+)
+
+const rosterFilter = reactive({
+  name: '',
+  idCard: '',
+  phone: '',
+  bizStatus: '',
+  source: '',
+})
+const rosterStatusOptions = [
+  { label: '正常在库', value: 'NORMAL' },
+  { label: '草稿', value: 'DRAFT' },
+  { label: '待镇初审', value: 'PENDING_TOWN_APPROVE' },
+  { label: '待居保匹配', value: 'PENDING_INSURANCE_MATCH' },
+  { label: '暂停', value: 'PAUSED' },
+  { label: '停止', value: 'STOPPED' },
+  { label: '死亡丧葬', value: 'FUNERAL' },
+  { label: '异常待处理', value: 'ABNORMAL' },
+  { label: '已归档', value: 'ARCHIVED' },
+]
+const rosterSourceOptions = ['村级录入', 'Excel导入', '历史台账', '死亡名单']
+
+const personDetailVisible = ref(false)
+const personDetailLoading = ref(false)
+const personDetail = ref<Person | null>(null)
+const personAlertVisible = ref(false)
+const personAlertLoading = ref(false)
+const personAlertTarget = ref<Person | null>(null)
+const personAlerts = ref<AlertItem[]>([])
+const personHistoryVisible = ref(false)
+const personHistoryLoading = ref(false)
+const personHistoryTarget = ref<Person | null>(null)
+const personHistoryRows = ref<BatchMemberPerson[]>([])
+const alertLifecycleVisible = ref(false)
+const alertLifecycleLoading = ref(false)
+const alertLifecycleItems = ref<AlertLifecycleItem[]>([])
+const alertLifecycleTitle = ref('处理流程')
+const templateDownloading = ref(false)
+const importing = ref(false)
+
+function isAdjustPayRow(row?: BatchMemberPerson | null) {
+  if (!row) return false
+  const no = String(row.batchNo || '').replace(/-/g, '')
+  if (no.length >= 6 && no.endsWith('99')) return true
+  const remark = String(row.remark || '')
+  return remark.includes('调标') || remark.includes('YEARLY_ADJUST')
+}
+
+function openPersonPay(row: BatchMemberPerson) {
+  currentPersonPay.value = row
+  personPayVisible.value = true
+}
+
+async function openPersonDetail(row: Person) {
+  personDetail.value = row
+  personDetailVisible.value = true
+  personDetailLoading.value = true
+  try {
+    const full = await fetchPersonDetail(String(row.id))
+    if (full) personDetail.value = full
+  } finally {
+    personDetailLoading.value = false
+  }
+}
+
+async function openPersonAlerts(row: Person) {
+  personAlertTarget.value = row
+  personAlertVisible.value = true
+  personAlertLoading.value = true
+  personAlerts.value = []
+  try {
+    personAlerts.value = await fetchPersonnelAlerts({
+      personnelId: String(row.id),
+      personName: row.name,
+      town: row.town,
+      village: row.village,
+    })
+  } finally {
+    personAlertLoading.value = false
+  }
+}
+
+function onPersonAlertClosed() {
+  personAlertTarget.value = null
+  personAlerts.value = []
+}
+
+async function openAlertLifecycle(row: AlertItem) {
+  alertLifecycleTitle.value = `处理流程 · ${row.type || row.id}`
+  alertLifecycleVisible.value = true
+  alertLifecycleLoading.value = true
+  alertLifecycleItems.value = row.lifecycle?.length ? [...row.lifecycle] : []
+  try {
+    const detail = await fetchAlertDetail(String(row.id))
+    if (detail?.lifecycle?.length) {
+      alertLifecycleItems.value = detail.lifecycle
+    } else if (!alertLifecycleItems.value.length && detail) {
+      alertLifecycleItems.value = [
+        {
+          actionLabel: '产生告警',
+          status: detail.status,
+          operator: '系统',
+          result: detail.content,
+          operateTime: detail.createdAt,
+        },
+      ]
+    }
+  } finally {
+    alertLifecycleLoading.value = false
+  }
+}
+
+function onAlertLifecycleClosed() {
+  alertLifecycleItems.value = []
+}
+
+async function openPersonHistory(row: Person) {
+  personHistoryTarget.value = row
+  personHistoryVisible.value = true
+  personHistoryLoading.value = true
+  personHistoryRows.value = []
+  try {
+    personHistoryRows.value = await fetchPersonnelPaymentHistory(String(row.id))
+  } finally {
+    personHistoryLoading.value = false
+  }
+}
+
+function onPersonHistoryClosed() {
+  personHistoryTarget.value = null
+  personHistoryRows.value = []
+}
+
+function applyRosterFilter() {
+  void peoplePager.resetAndLoad()
+}
+
+function resetRosterFilter() {
+  rosterFilter.name = ''
+  rosterFilter.idCard = ''
+  rosterFilter.phone = ''
+  rosterFilter.bizStatus = ''
+  rosterFilter.source = ''
+  void peoplePager.resetAndLoad()
+}
+
+async function onDownloadTemplate() {
+  if (!hasVillageScope.value) {
+    ElMessage.warning(
+      isAdmin.value ? '请先选择街镇与村居后再下载模板' : '当前账号未绑定村居,无法下载模板',
+    )
+    return
+  }
+  const role = userStore.roleInfo
+  const town = isAdmin.value ? filterTown.value : role?.town
+  const village = isAdmin.value ? filterVillage.value : role?.village
+  templateDownloading.value = true
+  try {
+    await downloadPersonnelImportTemplate(town, village)
+    ElMessage.success('模板已开始下载')
+  } finally {
+    templateDownloading.value = false
+  }
+}
+
+async function onImportFileChange(uploadFile: UploadFile) {
+  const file = uploadFile.raw
+  if (!file) return
+  if (!hasVillageScope.value) {
+    ElMessage.warning(
+      isAdmin.value ? '请先选择街镇与村居后再导入' : '当前账号未绑定村居,无法导入',
+    )
+    return
+  }
+  const role = userStore.roleInfo
+  const town = isAdmin.value ? filterTown.value : role?.town
+  const village = isAdmin.value ? filterVillage.value : role?.village
+  importing.value = true
+  try {
+    const res = (await importPersonnelExcel(file, town, village)) as {
+      successCount?: number
+      failCount?: number
+      message?: string
+    }
+    const ok = Number(res?.successCount ?? 0)
+    const fail = Number(res?.failCount ?? 0)
+    if (fail > 0) {
+      ElMessage.warning(res?.message || `导入完成:成功 ${ok} 条,失败 ${fail} 条`)
+    } else {
+      ElMessage.success(res?.message || `导入成功 ${ok} 条`)
+    }
+    await loadPeople()
+  } finally {
+    importing.value = false
+  }
+}
+
 const personDialogVisible = ref(false)
+const personFormRef = ref<FormInstance>()
 const saving = ref(false)
 const submittingId = ref('')
 const batchDrawerVisible = ref(false)
@@ -402,13 +903,48 @@ const {
   resetPage: resetBatchPage,
 } = useClientPagination(batches, { pageSize: BATCH_PAGE_SIZE })
 
+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,
+        name: rosterFilter.name.trim() || undefined,
+        idCard: rosterFilter.idCard.trim().replace(/\s/g, '') || undefined,
+        phone: rosterFilter.phone.trim() || undefined,
+        bizStatus: rosterFilter.bizStatus || undefined,
+        source: rosterFilter.source.trim() || undefined,
+        pageNum,
+        pageSize,
+      })
+    }
+    if (!role?.village) return { list: [], total: 0 }
+    return fetchPeoplePage({
+      town: role.town,
+      village: role.village,
+      name: rosterFilter.name.trim() || undefined,
+      idCard: rosterFilter.idCard.trim().replace(/\s/g, '') || undefined,
+      phone: rosterFilter.phone.trim() || undefined,
+      bizStatus: rosterFilter.bizStatus || undefined,
+      source: rosterFilter.source.trim() || undefined,
+      pageNum,
+      pageSize,
+    })
+  },
+  { pageSize: PEOPLE_PAGE_SIZE },
+)
 const {
   page: peoplePage,
   pageSize: peoplePageSize,
   total: peopleTotal,
-  pagedList: pagedPeople,
-  resetPage: resetPeoplePage,
-} = useClientPagination(people, { pageSize: PEOPLE_PAGE_SIZE })
+  list: pagedPeople,
+  loading: peopleLoading,
+  resetAndLoad: loadPeople,
+} = peoplePager
 
 /** 数据范围文案 */
 const villageScopeLabel = computed(() => {
@@ -429,16 +965,54 @@ const hasVillageScope = computed(() => {
   return Boolean(userStore.roleInfo?.village)
 })
 
-const personForm = reactive({
-  name: '',
-  idCard: '',
-  bankCard: '',
-  bankName: '',
-  socialInsuranceCode: '',
-  standard: 1280,
-  startMonth: '',
-  remark: '',
-})
+const personForm = ref(emptyPersonForm())
+
+function onPersonFormUpdate(v: Person) {
+  personForm.value = v
+}
+
+const personFormRules: FormRules = {
+  name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
+  idCard: [
+    { required: true, message: '请输入身份证号', trigger: 'blur' },
+    {
+      validator: (_r, v, cb) => {
+        if (!isValidIdCard(String(v || ''))) cb(new Error('身份证号须为18位合法号码'))
+        else cb()
+      },
+      trigger: 'blur',
+    },
+  ],
+  phoneNumber: [
+    { required: true, message: '请输入手机号', trigger: 'blur' },
+    {
+      validator: (_r, v, cb) => {
+        if (!isValidPhone(String(v || ''))) cb(new Error('手机号格式不正确'))
+        else cb()
+      },
+      trigger: 'blur',
+    },
+  ],
+  bankCard: [{ required: true, message: '请输入银行卡号', trigger: 'blur' }],
+  heirIdNumber: [
+    {
+      validator: (_r, v, cb) => {
+        if (!isValidIdCardOptional(String(v || ''))) cb(new Error('继承人身份证号格式不正确'))
+        else cb()
+      },
+      trigger: 'blur',
+    },
+  ],
+  heirPhone: [
+    {
+      validator: (_r, v, cb) => {
+        if (!isValidPhoneOptional(String(v || ''))) cb(new Error('继承人手机号格式不正确'))
+        else cb()
+      },
+      trigger: 'blur',
+    },
+  ],
+}
 
 /** 当前发放周期 yyyyMM */
 const currentPayMonth = computed(() =>
@@ -515,6 +1089,15 @@ function openPersonDialog() {
     )
     return
   }
+  const role = userStore.roleInfo
+  const town = isAdmin.value ? filterTown.value : role?.town || ''
+  const village = isAdmin.value ? filterVillage.value : role?.village || ''
+  personForm.value = emptyPersonForm({
+    town,
+    village,
+    source: '村级录入',
+    payeeName: '',
+  })
   personDialogVisible.value = true
 }
 
@@ -538,6 +1121,8 @@ async function openMemberDialog(row: VillageBatch) {
   memberLoading.value = true
   memberSelectedIds.value = []
   try {
+    // ensure 可编辑村批时会纠正误标「待镇初审」的脏人员状态
+    await ensureVillageMonthBatch(row.town, row.village, row.batchNo)
     const [allPeople, pickedIds] = await Promise.all([
       fetchPeople({ town: row.town, village: row.village }),
       fetchBatchMemberIds(row.id),
@@ -545,6 +1130,12 @@ async function openMemberDialog(row: VillageBatch) {
     // 候选:本村人员(归档除外);暂停/停止/死亡仍列出但默认不勾选
     memberCandidates.value = allPeople.filter((p) => p.status !== '已归档')
     const picked = new Set(pickedIds.map(String))
+    const defaultPick = (s?: string) =>
+      s === '正常在库' ||
+      s === '草稿' ||
+      s === '资格已核验' ||
+      s === '待镇初审' ||
+      s === '待镇审批'
     await nextTick()
     const table = memberTableRef.value
     if (!table) return
@@ -558,7 +1149,7 @@ async function openMemberDialog(row: VillageBatch) {
       })
     } else {
       memberCandidates.value.forEach((p) => {
-        if (!isExcludedMemberStatus(p.status) && (p.status === '正常在库' || p.status === '草稿')) {
+        if (!isExcludedMemberStatus(p.status) && defaultPick(p.status)) {
           table.toggleRowSelection(p, true)
         }
       })
@@ -664,19 +1255,32 @@ async function onSavePerson() {
     ElMessage.warning(isAdmin.value ? '请先选择街镇与村居' : '当前账号未绑定村居,无法保存')
     return
   }
-  if (!personForm.name || !personForm.idCard) {
-    ElMessage.warning('请填写姓名和身份证号')
-    return
-  }
+  const valid = await personFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  const f = personForm.value
   const role = userStore.roleInfo!
   const town = isAdmin.value ? filterTown.value : role.town
   const village = isAdmin.value ? filterVillage.value : role.village
   saving.value = true
   try {
     await savePerson({
-      ...personForm,
+      ...f,
+      id: undefined,
       town,
       village,
+      name: f.name.trim(),
+      idCard: f.idCard.trim(),
+      phoneNumber: (f.phoneNumber || '').trim(),
+      bankCard: f.bankCard.trim(),
+      payeeName: (f.payeeName || f.name).trim(),
+      bankName: (f.bankName || '').trim(),
+      socialInsuranceCode: (f.socialInsuranceCode || '').trim(),
+      heirName: (f.heirName || '').trim(),
+      heirIdNumber: (f.heirIdNumber || '').trim(),
+      heirBankCard: (f.heirBankCard || '').trim(),
+      heirPhone: (f.heirPhone || '').trim(),
+      remark: (f.remark || '').trim(),
+      source: f.source || '村级录入',
     })
     ElMessage.success('人员保存成功')
     personDialogVisible.value = false
@@ -711,32 +1315,9 @@ async function loadBatches() {
   }
 }
 
-/** 加载人员:同上 */
-async function loadPeople() {
-  peopleLoading.value = true
-  try {
-    const all = await fetchPeople()
-    const role = userStore.roleInfo
-    if (isAdmin.value) {
-      people.value = all.filter((p) => {
-        if (filterTown.value && p.town !== filterTown.value) return false
-        if (filterVillage.value && p.village !== filterVillage.value) return false
-        return true
-      })
-    } else if (!role?.village) {
-      people.value = []
-    } else {
-      people.value = filterByRoleScope(all, role)
-    }
-    resetPeoplePage()
-  } finally {
-    peopleLoading.value = false
-  }
-}
-
 onMounted(() => {
   loadBatches()
-  loadPeople()
+  void loadPeople()
 })
 
 // 切换模拟角色时重新按范围加载
@@ -841,6 +1422,31 @@ watch(
     overflow: hidden;
   }
 
+  &__history-tip {
+    flex-shrink: 0;
+    margin: 0 0 8px;
+    font-size: 12px;
+    color: @text-secondary;
+    line-height: 1.5;
+  }
+
+  &__roster-filter {
+    flex-shrink: 0;
+    margin-bottom: 4px;
+  }
+
+  &__import-upload {
+    display: inline-flex;
+  }
+
+  &__pay-tip {
+    margin-top: 4px;
+    font-size: 12px;
+    color: @text-secondary;
+    font-weight: 400;
+    line-height: 1.5;
+  }
+
   &__dialog-tip {
     margin: 0 0 12px;
     font-size: 12px;

+ 10 - 5
src/views/disk/DiskReturnView.vue

@@ -10,7 +10,7 @@
           系统管理员可查看全区各镇回盘记录,请按街镇筛选后操作。
         </template>
         <template v-else>
-          出盘后在此上传回盘文件;全部成功或部分失败处理完后点「归档」完成本月闭环。失败人员可查看原因并处理告警。
+          出盘后在此上传回盘文件;回盘确认完成后点「归档」将批次标为已归档、完成本月闭环(不再出现在工作台待办)。失败人员可查看原因并处理告警。
         </template>
       </p>
       <div v-if="isAdmin" class="query-panel disk-return-view__filter">
@@ -30,6 +30,9 @@
       <el-table v-loading="loading" :data="pagedList" border stripe size="small">
         <el-table-column prop="batchNo" label="批次号" width="100" align="center" />
         <el-table-column prop="town" label="街镇" width="90" />
+        <el-table-column prop="village" label="村居" width="110" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.village || '—' }}</template>
+        </el-table-column>
         <el-table-column prop="fileName" label="清单文件名" min-width="200" show-overflow-tooltip />
         <el-table-column label="回盘文件" min-width="180" show-overflow-tooltip>
           <template #default="{ row }">
@@ -108,7 +111,7 @@
             <el-option
               v-for="item in pendingDiskOptions"
               :key="item.id"
-              :label="`${item.batchNo} · ${item.town} · ${item.fileName}`"
+              :label="`${item.batchNo} · ${item.town}${item.village ? ' · ' + item.village : ''} · ${item.fileName}`"
               :value="item.id"
             />
           </el-select>
@@ -322,13 +325,15 @@ async function submitUpload() {
 }
 
 function canArchive(row: DiskRecord) {
-  return row.status === '已回盘' || row.returnStatus === '成功' || row.returnStatus === '部分失败'
+  // 仅「已回盘」可归档;已归档不再显示按钮
+  return row.status === '已回盘'
 }
 
 async function onArchive(row: DiskRecord) {
+  const place = [row.town, row.village].filter(Boolean).join(' · ')
   try {
     await ElMessageBox.confirm(
-      `确认将批次 ${row.batchNo}(${row.town})归档?归档后本月业务闭环,不再出现在待办列表。`,
+      `确认将批次 ${row.batchNo}${place ? `(${place})` : ''}归档?\n归档后清单状态变为「已归档」,本月业务闭环,不再出现在工作台待办。`,
       '归档确认',
       { type: 'warning', confirmButtonText: '确认归档', cancelButtonText: '取消' },
     )
@@ -338,7 +343,7 @@ async function onArchive(row: DiskRecord) {
   archivingId.value = row.id
   try {
     await archiveToNextMonth(row.id)
-    ElMessage.success('批次已归档')
+    ElMessage.success('批次已归档:清单状态已更新为「已归档」')
     await loadData()
   } finally {
     archivingId.value = ''

+ 164 - 19
src/views/monthly/MonthlyPaymentView.vue

@@ -135,7 +135,7 @@
     <el-dialog
       v-model="detailVisible"
       :title="detailTitle"
-      width="860px"
+      width="980px"
       destroy-on-close
       @closed="onDetailClosed"
     >
@@ -158,30 +158,75 @@
           <el-descriptions-item label="扣减金额">
             {{ formatAmount(currentRow.deductAmount) }}
           </el-descriptions-item>
-          <el-descriptions-item label="合计金额" :span="3">
+          <el-descriptions-item label="合计金额(批次头)">
             {{ formatAmount(currentRow.totalAmount) }}
           </el-descriptions-item>
+          <el-descriptions-item label="总发放金额(明细汇总)" :span="2">
+            <strong>{{ formatAmount(detailAmountSummary.payable) }}</strong>
+            <span class="muted" style="margin-left: 8px">
+              <template v-if="isAdjustDetailBatch">(调标差额批:应发=补发 {{ formatAmount(detailAmountSummary.supplement) }})</template>
+              <template v-else>
+                (标准 {{ formatAmount(detailAmountSummary.monthly) }}
+                + 补发 {{ formatAmount(detailAmountSummary.supplement) }}
+                + 丧葬 {{ formatAmount(detailAmountSummary.funeral) }}
+                - 扣减 {{ formatAmount(detailAmountSummary.deduct) }})
+              </template>
+            </span>
+          </el-descriptions-item>
         </el-descriptions>
-        <p class="monthly-payment-view__sub-tip">本村本批次人员明细</p>
-        <el-table v-loading="detailLoading" :data="pagedDetailPeople" border stripe size="small" max-height="360">
+        <p class="monthly-payment-view__sub-tip">
+          总发放按本批发放明细实时汇总;含停止后剔除、补发/丧葬等变动。分列见下表或点「详情」。
+        </p>
+        <el-table
+          v-loading="detailLoading"
+          :data="pagedDetailPeople"
+          border
+          stripe
+          size="small"
+          max-height="360"
+          show-summary
+          :summary-method="detailTableSummary"
+        >
           <el-table-column prop="name" label="姓名" width="80" />
-          <el-table-column label="身份证" min-width="160">
+          <el-table-column label="身份证" min-width="150">
             <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
           </el-table-column>
-          <el-table-column label="标准(元/月)" width="100" align="right">
-            <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+          <el-table-column v-if="!isAdjustDetailBatch" label="标准金额" width="100" align="right">
+            <template #default="{ row }">
+              {{ formatAmount(row.monthlyAmount ?? row.standard) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="补发金额" width="96" align="right">
+            <template #default="{ row }">{{ formatAmount(row.supplementAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="丧葬金额" width="96" align="right">
+            <template #default="{ row }">{{ formatAmount(row.funeralAmount) }}</template>
           </el-table-column>
-          <el-table-column label="人员状态" width="110" align="center">
+          <el-table-column label="扣减" width="88" align="right">
+            <template #default="{ row }">{{ formatAmount(row.deductAmount) }}</template>
+          </el-table-column>
+          <el-table-column label="总计金额" width="104" align="right">
+            <template #default="{ row }">
+              <strong>{{ formatAmount(row.payableAmount) }}</strong>
+            </template>
+          </el-table-column>
+          <el-table-column label="人员状态" width="100" align="center">
             <template #default="{ row }">
               <StatusTag :status="row.status" />
             </template>
           </el-table-column>
-          <el-table-column label="居保状态" width="110" align="center">
+          <el-table-column label="居保状态" width="100" align="center">
             <template #default="{ row }">
               <StatusTag :status="row.insuranceStatus" />
             </template>
           </el-table-column>
-          <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
+          <el-table-column label="操作" width="72" align="center" fixed="right">
+            <template #default="{ row }">
+              <el-button link type="primary" size="small" @click="openPersonPay(row)">
+                详情
+              </el-button>
+            </template>
+          </el-table-column>
         </el-table>
         <TablePagination
           v-model:page="detailPage"
@@ -190,6 +235,70 @@
         />
       </template>
     </el-dialog>
+
+    <!-- 单人发放金额详情 -->
+    <el-dialog
+      v-model="personPayVisible"
+      :title="personPayTitle"
+      width="520px"
+      append-to-body
+      destroy-on-close
+    >
+      <template v-if="currentPersonPay">
+        <el-descriptions :column="1" border size="small">
+          <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
+          <el-descriptions-item label="身份证">
+            {{ maskIdCard(currentPersonPay.idCard) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="街镇/村居">
+            {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
+          </el-descriptions-item>
+          <el-descriptions-item label="银行卡">
+            {{ maskBankCard(currentPersonPay.bankCard) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="开户行">
+            {{ currentPersonPay.bankName || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item v-if="!isAdjustDetailBatch" label="月补标准(元/月)">
+            {{ formatAmount(currentPersonPay.monthlyAmount ?? currentPersonPay.standard) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="一次性代发">
+            {{ formatAmount(currentPersonPay.oneTimeAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="补发金额">
+            {{ formatAmount(currentPersonPay.supplementAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="丧葬费">
+            {{ formatAmount(currentPersonPay.funeralAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="扣减金额">
+            {{ formatAmount(currentPersonPay.deductAmount) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="应发合计">
+            <strong>{{ formatAmount(currentPersonPay.payableAmount) }}</strong>
+          </el-descriptions-item>
+          <el-descriptions-item label="实发金额">
+            {{
+              currentPersonPay.actualAmount == null
+                ? '未回盘'
+                : formatAmount(currentPersonPay.actualAmount)
+            }}
+          </el-descriptions-item>
+          <el-descriptions-item label="回盘结果">
+            {{ currentPersonPay.returnResult || '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="人员状态">
+            <StatusTag :status="currentPersonPay.status" />
+          </el-descriptions-item>
+          <el-descriptions-item label="居保状态">
+            <StatusTag :status="currentPersonPay.insuranceStatus" />
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">
+            {{ currentPersonPay.remark || '—' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -210,8 +319,9 @@ import {
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
-import { formatAmount, maskIdCard } from '@/utils/mask'
-import type { Person, VillageMonthlyBatch } from '@/types/biz'
+import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
+import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import type { BatchMemberPerson, VillageMonthlyBatch } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
@@ -231,11 +341,50 @@ const actingId = ref('')
 const detailVisible = ref(false)
 const detailLoading = ref(false)
 const currentRow = ref<VillageMonthlyBatch | null>(null)
-const detailPeople = ref<Person[]>([])
+const detailPeople = ref<BatchMemberPerson[]>([])
+const isAdjustDetailBatch = computed(() => {
+  const b = currentRow.value
+  if (!b) return false
+  const remarks = String((b as any).remarks || (b as any).remark || '')
+  const no = String(b.batchNo || '')
+  return remarks.includes('调标') || remarks.includes('YEARLY_ADJUST') || /99(?:-|)/.test(no)
+})
+
+const detailAmountSummary = computed(() => sumBatchMemberAmounts(detailPeople.value))
+
+function detailTableSummary(param: {
+  columns: Array<{ label?: string; property?: string }>
+}) {
+  const sum = detailAmountSummary.value
+  return param.columns.map((col, index) => {
+    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)
+    return ''
+  })
+}
+
+const personPayVisible = ref(false)
+const currentPersonPay = ref<BatchMemberPerson | null>(null)
 const batchDrawerVisible = ref(false)
 const batchDrawerId = ref('')
 const batchDrawerTab = ref('detail')
 
+const personPayTitle = computed(() => {
+  const p = currentPersonPay.value
+  if (!p) return '人员发放详情'
+  return `${p.name} · 批次 ${p.batchNo || currentRow.value?.batchNo || ''} 发放详情`
+})
+
+function openPersonPay(row: BatchMemberPerson) {
+  currentPersonPay.value = row
+  personPayVisible.value = true
+}
+
 function openBatchDrawer(row: VillageMonthlyBatch, tab: 'detail' | 'flow' = 'detail') {
   if (!row.id) return
   batchDrawerId.value = row.id
@@ -359,7 +508,7 @@ async function onIssueDisk(row: VillageMonthlyBatch) {
 
 async function onExportFile(row: VillageMonthlyBatch) {
   if (row.status === '已审核' || row.status === '待审核' || row.status === '待提交') {
-    ElMessage.info('请先完成出盘后再导出对照文件;出盘成功后文件名会显示在批次信息中')
+    ElMessage.info('请先完成出盘后再导出发放清单')
     return
   }
   const res = (await exportMonthlyFile(row.id)) as { ok?: boolean; fileName?: string; message?: string }
@@ -367,11 +516,7 @@ async function onExportFile(row: VillageMonthlyBatch) {
     ElMessage.warning(res.message || '导出失败')
     return
   }
-  ElMessage.success(
-    res?.fileName
-      ? `出盘文件:${res.fileName}(线下签字发放,系统不提供资金支付)`
-      : '已生成对照文件,请按线下流程办理',
-  )
+  ElMessage.success(res?.fileName ? `已下载:${res.fileName}` : '发放清单已下载')
 }
 
 async function loadData() {

+ 37 - 3
src/views/reports/ReportsView.vue

@@ -14,12 +14,36 @@
         <!-- 人社优先展示金额;农委优先展示资格后再带金额 -->
         <el-table-column
           v-if="isHr || isAdminDistrict"
-          label="预计金额(元)"
-          min-width="140"
+          label="应发合计(元)"
+          min-width="130"
           align="right"
         >
           <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
         </el-table-column>
+        <el-table-column
+          v-if="isHr || isAdminDistrict"
+          label="区承担(元)"
+          min-width="120"
+          align="right"
+        >
+          <template #default="{ row }">{{ formatAmount(row.districtAmount) }}</template>
+        </el-table-column>
+        <el-table-column
+          v-if="isHr || isAdminDistrict"
+          label="镇承担(元)"
+          min-width="120"
+          align="right"
+        >
+          <template #default="{ row }">{{ formatAmount(row.townAmount) }}</template>
+        </el-table-column>
+        <el-table-column
+          v-if="isHr || isAdminDistrict"
+          label="区/镇比例"
+          width="100"
+          align="center"
+        >
+          <template #default="{ row }">{{ row.shareLabel }}</template>
+        </el-table-column>
         <el-table-column prop="withLandCount" label="有土地退养" min-width="120" align="center" />
         <el-table-column prop="withoutLandCount" label="无土地有资格" min-width="130" align="center" />
         <el-table-column
@@ -632,6 +656,7 @@ import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
 import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
+import { splitByShare } from '@/utils/fundShare'
 import type { AlertItem, Person, VillageBatch } from '@/types/biz'
 
 const userStore = useUserStore()
@@ -832,7 +857,16 @@ const townSummary = computed(() => {
     const cur = map.get(a.town)
     if (cur) cur.alertCount += 1
   }
-  return Array.from(map.entries()).map(([town, v]) => ({ town, ...v }))
+  return Array.from(map.entries()).map(([town, v]) => {
+    const share = splitByShare(v.amount, undefined, undefined, town)
+    return {
+      town,
+      ...v,
+      districtAmount: share.districtAmount,
+      townAmount: share.townAmount,
+      shareLabel: share.shareLabel,
+    }
+  })
 })
 
 /** 镇经发 / 镇社区事务:按村汇总 */

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 725 - 92
src/views/special/SpecialBusinessView.vue


+ 159 - 24
src/views/status/StatusMaintenanceView.vue

@@ -2,7 +2,15 @@
   <div class="status-maintenance-view">
     <PagePanel title="补助标准管理">
       <template #extra>
-        <el-button type="primary" size="small" @click="openCreateDialog">新增标准</el-button>
+        <el-button
+          type="primary"
+          size="small"
+          :disabled="!canOpenCreate"
+          :title="createBlockedTip || undefined"
+          @click="openCreateDialog"
+        >
+          新增标准
+        </el-button>
       </template>
 
       <!-- 系统管理员:按街镇筛选全区标准 -->
@@ -37,9 +45,17 @@
         </el-table-column>
         <el-table-column prop="date" label="生效日期" width="110" align="center" />
         <el-table-column prop="reason" label="调整原因" min-width="160" show-overflow-tooltip />
-        <el-table-column label="操作" width="120" align="center" fixed="right">
+        <el-table-column label="当前" width="72" align="center">
           <template #default="{ row }">
-            <template v-if="isLatestStandard(row as SubsidyStandard)">
+            <el-tag v-if="isCurrentStandard(row as SubsidyStandard)" size="small" type="success">
+              有效
+            </el-tag>
+            <span v-else class="muted">历史</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200" align="center" fixed="right">
+          <template #default="{ row }">
+            <template v-if="isCurrentStandard(row as SubsidyStandard)">
               <el-button
                 link
                 type="primary"
@@ -58,6 +74,15 @@
             >
               查看
             </el-button>
+            <el-button
+              v-if="isAdmin"
+              link
+              type="danger"
+              size="small"
+              @click="onDeleteStandard(row as SubsidyStandard)"
+            >
+              删除
+            </el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -101,6 +126,7 @@
             placeholder="请选择街镇"
             class="is-editable"
             style="width: 100%"
+            @change="onCreateTownChange"
           >
             <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
           </el-select>
@@ -112,10 +138,14 @@
             type="year"
             value-format="YYYY"
             placeholder="请选择年度"
-            :disabled="formMode === 'edit'"
+            :disabled="formMode === 'edit' || formMode === 'create'"
+            :disabled-date="disableNonCurrentYear"
             class="is-editable"
             style="width: 100%"
           />
+          <p v-if="formMode === 'create'" class="status-maintenance-view__field-tip">
+            仅允许新增本年度({{ currentYear }})标准;历史年度与未来年度不可新增。本街镇本年度已有标准时请使用「调整标准」。
+          </p>
         </el-form-item>
         <el-form-item label="月补助(元)" prop="monthly">
           <el-input-number
@@ -170,10 +200,10 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref, watch } from 'vue'
 import type { FormInstance, FormRules } from 'element-plus'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
-import { createStandard, fetchStandards, updateStandard } from '@/api/biz'
+import { createStandard, deleteStandard, fetchStandards, updateStandard } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
@@ -198,26 +228,50 @@ const formRef = ref<FormInstance>()
 
 const currentTown = computed(() => userStore.roleInfo?.town || '')
 
-/** 各镇各自最新年度标准 id(管理员多镇时按镇判断) */
-const latestIdsByTown = computed(() => {
-  const map = new Map<string, number>()
-  const byTown = new Map<string, SubsidyStandard[]>()
-  for (const row of standards.value) {
-    const list = byTown.get(row.town) || []
-    list.push(row)
-    byTown.set(row.town, list)
+/** 业务本年度:优先系统发放月,否则取自然年 */
+const currentYear = computed(() => {
+  const m = String(userStore.currentMonth || '')
+  if (/^\d{4}/.test(m)) return Number(m.slice(0, 4))
+  return new Date().getFullYear()
+})
+
+/**
+ * 是否当前有效标准:只认后端 current 标志。
+ * 业务约定:每个街镇同一时间只应有一条有效标准(不按「日期最新」猜)。
+ */
+function isCurrentStandard(row: SubsidyStandard) {
+  return row.current === true
+}
+
+/** 某街镇是否已有本年度标准(有则禁止再新增) */
+function townHasCurrentYearStandard(town: string) {
+  if (!town) return false
+  const y = currentYear.value
+  return standards.value.some((s) => s.town === town && Number(s.year) === y)
+}
+
+const createBlockedTip = computed(() => {
+  if (isAdmin.value && !filterTown.value && !form.town) {
+    return '请先选择街镇后再新增本年度标准'
   }
-  for (const [town, list] of byTown) {
-    const sorted = [...list].sort((a, b) =>
-      b.year !== a.year ? b.year - a.year : b.date.localeCompare(a.date),
-    )
-    if (sorted[0]) map.set(town, sorted[0].id)
+  const town = isAdmin.value ? filterTown.value || form.town : 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)
   }
-  return map
+  return Boolean(currentTown.value) && !townHasCurrentYearStandard(currentTown.value)
 })
 
-function isLatestStandard(row: SubsidyStandard) {
-  return latestIdsByTown.value.get(row.town) === row.id
+function disableNonCurrentYear(date: Date) {
+  return date.getFullYear() !== currentYear.value
 }
 
 const form = reactive({
@@ -248,7 +302,7 @@ const {
 
 function resetForm() {
   form.town = isAdmin.value ? filterTown.value || '' : currentTown.value
-  form.year = ''
+  form.year = String(currentYear.value)
   form.monthly = undefined
   form.funeral = undefined
   form.date = ''
@@ -262,11 +316,38 @@ function openViewDialog(row: SubsidyStandard) {
 }
 
 function openCreateDialog() {
+  const town = isAdmin.value ? filterTown.value : currentTown.value
+  if (isAdmin.value && !town) {
+    ElMessage.warning('请先在上方选择街镇后再新增')
+    return
+  }
+  if (!town) {
+    ElMessage.warning('当前账号未绑定街镇,无法新增标准')
+    return
+  }
+  if (townHasCurrentYearStandard(town)) {
+    ElMessage.warning(
+      `「${town}」${currentYear.value} 年度已有补助标准,禁止重复新增;如需变更请点击「调整标准」`,
+    )
+    return
+  }
   formMode.value = 'create'
   resetForm()
+  form.year = String(currentYear.value)
   formDialogVisible.value = true
 }
 
+function onCreateTownChange(town: string) {
+  if (formMode.value !== 'create') return
+  form.year = String(currentYear.value)
+  if (town && townHasCurrentYearStandard(town)) {
+    ElMessage.warning(
+      `「${town}」${currentYear.value} 年度已有补助标准,禁止新增;请关闭后使用「调整标准」`,
+    )
+    formDialogVisible.value = false
+  }
+}
+
 function openEditDialog(row: SubsidyStandard) {
   formMode.value = 'edit'
   editingId.value = row.id
@@ -297,12 +378,30 @@ async function submitForm() {
   const monthly = form.monthly ?? 0
   const funeral = form.funeral ?? 0
 
+  if (formMode.value === 'create') {
+    if (year !== currentYear.value) {
+      ElMessage.warning(`仅允许新增本年度(${currentYear.value})标准,不可新增历史或未来年度`)
+      form.year = String(currentYear.value)
+      return
+    }
+    if (!form.town) {
+      ElMessage.warning('请选择街镇')
+      return
+    }
+    if (townHasCurrentYearStandard(form.town)) {
+      ElMessage.warning(
+        `「${form.town}」${currentYear.value} 年度已有补助标准,禁止重复新增;请使用「调整标准」`,
+      )
+      return
+    }
+  }
+
   submitting.value = true
   try {
     if (formMode.value === 'create') {
       const res = await createStandard({
         town: form.town,
-        year,
+        year: currentYear.value,
         monthly,
         funeral,
         date: form.date,
@@ -314,8 +413,10 @@ async function submitForm() {
       }
       ElMessage.success('新增标准成功')
     } else if (editingId.value != null) {
+      const editing = standards.value.find((s) => s.id === editingId.value)
       const res = await updateStandard({
         id: editingId.value,
+        rawId: editing?.rawId,
         monthly,
         funeral,
         date: form.date,
@@ -334,6 +435,28 @@ async function submitForm() {
   }
 }
 
+async function onDeleteStandard(row: SubsidyStandard) {
+  const tip = isCurrentStandard(row)
+    ? `「${row.town}」${row.monthly} 元是当前有效标准,删除后将自动恢复同镇上一档为有效。确认删除?`
+    : `确认删除「${row.town}」${row.year} 年标准(月补 ${row.monthly})?删除后列表不再展示。`
+  try {
+    await ElMessageBox.confirm(tip, '删除补助标准', {
+      type: 'warning',
+      confirmButtonText: '确认删除',
+      cancelButtonText: '取消',
+    })
+  } catch {
+    return
+  }
+  const res = await deleteStandard({ id: row.id, rawId: row.rawId })
+  if (!res.ok) {
+    ElMessage.warning(res.message || '删除失败')
+    return
+  }
+  ElMessage.success(res.message || '已删除')
+  await loadStandards()
+}
+
 async function loadStandards() {
   standardLoading.value = true
   try {
@@ -372,5 +495,17 @@ watch(
     margin-bottom: 10px;
     padding: 8px 12px 0;
   }
+
+  &__field-tip {
+    margin: 4px 0 0;
+    font-size: 12px;
+    color: @text-secondary;
+    line-height: 1.4;
+  }
+}
+
+.muted {
+  color: @text-secondary;
+  font-size: 12px;
 }
 </style>

+ 10 - 7
vite.config.ts

@@ -15,6 +15,8 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
  * 2) npm run dev:local    → 本机后台
  * 3) npm run dev:online   → 线上后台
  * 4) 改下面 DEFAULT_API_ENV,或设环境变量 VITE_API_ENV=local|online
+ *
+ * 注意:不能用 vite --mode local(与 .env.local 后缀冲突),故用 backend-local / backend-online。
  */
 const DEFAULT_API_ENV: 'online' | 'local' = 'online'
 
@@ -23,17 +25,18 @@ const API_TARGETS = {
   local: 'http://localhost:8088',
 } as const
 
+function resolveApiEnv(mode: string, fromEnv: string): 'online' | 'local' {
+  if (mode === 'backend-local' || mode === 'local') return 'local'
+  if (mode === 'backend-online' || mode === 'online') return 'online'
+  if (fromEnv === 'local' || fromEnv === 'online') return fromEnv
+  return DEFAULT_API_ENV
+}
+
 // https://vite.dev/config/
 export default defineConfig(({ mode }) => {
   const env = loadEnv(mode, process.cwd(), '')
   const fromEnv = (env.VITE_API_ENV || process.env.VITE_API_ENV || '').toLowerCase()
-  // vite --mode local / online 优先;否则看 VITE_API_ENV;再否则 DEFAULT
-  const apiEnv: 'online' | 'local' =
-    mode === 'local' || mode === 'online'
-      ? mode
-      : fromEnv === 'local' || fromEnv === 'online'
-        ? fromEnv
-        : DEFAULT_API_ENV
+  const apiEnv = resolveApiEnv(mode, fromEnv)
 
   const apiTarget = API_TARGETS[apiEnv]
   console.log(`[vite] 开发代理后台 → ${apiEnv} (${apiTarget}/sjnmtybt)`)

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů