Quellcode durchsuchen

人员模型只补外部清单缺少的列(户籍拆分、联系人、刑期、注销、继承人证件/银行/地址、同城或异地等),不删已有列
- 服刑暂停审批写入刑期;死亡登记审批写入注销信息和继承人扩展项;恢复发放保留刑期
- 人员详情、暂停弹窗、死亡录入和死亡详情展示这些字段。对照和测试见 `docs/人员字段补齐与业务流程-20261012.md`
- 居保匹配前先对未死亡人员调用死亡接口;地址未配置时不发请求。查到死亡只出预警,不停发、不发丧葬补贴。丧葬补贴仍须家属登记并经分管领导审批通过

DESKTOP-6LTVLN7\Liumouren vor 2 Wochen
Ursprung
Commit
b8212977bf
40 geänderte Dateien mit 3701 neuen und 987 gelöschten Zeilen
  1. 3 3
      public/config/config.js
  2. 69 2
      src/api/biz.ts
  3. 52 10
      src/api/sjnmtybt/mappers.ts
  4. 428 42
      src/api/sjnmtybt/realBiz.ts
  5. 4 0
      src/components.d.ts
  6. 17 3
      src/components/biz/AlertLifecycleTimeline.vue
  7. 20 12
      src/components/biz/BatchDetailDrawer.vue
  8. 3 2
      src/components/biz/PersonStatusTimeline.vue
  9. 19 2
      src/components/biz/PersonnelDetailDescriptions.vue
  10. 61 0
      src/components/biz/PersonnelFieldLabel.vue
  11. 68 12
      src/components/biz/PersonnelFormFields.vue
  12. 2 0
      src/components/common/ChartBox.vue
  13. 59 0
      src/components/common/PersonNameCell.vue
  14. 204 0
      src/components/common/PieMixPanel.vue
  15. 2 2
      src/components/common/SensitiveRevealSwitch.vue
  16. 33 2
      src/components/common/StatCards.vue
  17. 236 0
      src/components/common/WorkbenchTipsPanel.vue
  18. 1 1
      src/constants/menus.ts
  19. 344 40
      src/constants/personnelFields.ts
  20. 28 1
      src/layouts/MainLayout.vue
  21. 1 1
      src/router/routes.ts
  22. 16 2
      src/stores/user.ts
  23. 63 5
      src/types/biz.ts
  24. 14 3
      src/utils/appConfig.ts
  25. 10 10
      src/utils/buildDashboard.ts
  26. 18 0
      src/utils/deathWarning.ts
  27. 57 2
      src/utils/statusLabels.ts
  28. 5 5
      src/views/admin/AdminBatchesView.vue
  29. 17 3
      src/views/admin/PersonnelAdminView.vue
  30. 252 47
      src/views/alerts/AlertsView.vue
  31. 426 72
      src/views/approvals/ApprovalsView.vue
  32. 83 15
      src/views/base/BaseInfoView.vue
  33. 419 381
      src/views/dashboard/DashboardView.vue
  34. 9 0
      src/views/disk/DiskReturnView.vue
  35. 133 3
      src/views/insurance/InsuranceMatchView.vue
  36. 1 1
      src/views/monthly/MonthlyPaymentView.vue
  37. 183 152
      src/views/reports/ReportsView.vue
  38. 142 19
      src/views/special/SpecialBusinessView.vue
  39. 181 120
      src/views/status/StatusMaintenanceView.vue
  40. 18 12
      src/views/system/SystemAdminView.vue

+ 3 - 3
public/config/config.js

@@ -24,7 +24,7 @@ window.AppConfig = {
   // 必须为 false:业务数据统一走 sjnmtybt-server → DMS,禁止前端 mock 造假
   useMock: false,
   // 演示用当前角色(后续改为登录接口写入;页面切换后会写入 localStorage,刷新不丢)
-  currentRole: 'admin',
+  currentRole: 'social',
   // 当前发放周期(对齐后端 payMonth)
   currentMonth: '2026-08',
 
@@ -58,7 +58,7 @@ window.AppConfig = {
     },
     {
       key: 'leader',
-      label: '分管领导',
+      label: '镇社区受理中心分管领导',
       org: '叶榭镇社区事务受理中心',
       allData: false,
       town: '叶榭镇',
@@ -87,7 +87,7 @@ window.AppConfig = {
     ],
     leader: ['/dashboard', '/approvals', '/reports', '/alerts'],
     agri: ['/dashboard', '/reports'],
-    hr: ['/dashboard', '/reports', '/alerts'],
+    hr: ['/dashboard', '/reports', '/alerts', '/status'],
     admin: [
       '/dashboard',
       '/base',

+ 69 - 2
src/api/biz.ts

@@ -53,6 +53,7 @@ function mockPaged<T>(list: T[], pageNum = 1, pageSize = 10) {
 export function fetchDashboard(params: {
   roleKey: string
   currentMonth: string
+  light?: boolean
 }) {
   if (useMock()) {
     const role =
@@ -63,6 +64,16 @@ export function fetchDashboard(params: {
   return real.fetchDashboard(params)
 }
 
+export function fetchTownVillagePayTable(params: {
+  roleKey: string
+  currentMonth: string
+}) {
+  if (useMock()) {
+    return delay({ people: [], amount: [] })
+  }
+  return real.fetchTownVillagePayTable(params)
+}
+
 /** 工作台待办已读 */
 export function markWorkbenchTodoRead(todoKey: string) {
   if (useMock()) return delay(undefined)
@@ -208,6 +219,50 @@ export function exportPersonnelImportFailRows(failRows: real.PersonnelImportFail
   return real.exportPersonnelImportFailRows(failRows)
 }
 
+export function exportPersonnelRoster(params?: real.PeoplePageParams) {
+  if (useMock()) {
+    ElMessage.info('模拟模式:请切换联调后导出人员列表')
+    return delay(undefined)
+  }
+  return real.exportPersonnelRoster(params)
+}
+
+export function exportPersonnelLedger(params?: real.PeoplePageParams) {
+  if (useMock()) {
+    ElMessage.info('模拟模式:请切换联调后导出人员台账')
+    return delay(undefined)
+  }
+  return real.exportPersonnelLedger(params)
+}
+
+export function exportPersonnelNewMonth(params?: real.PeoplePageParams) {
+  if (useMock()) {
+    ElMessage.info('模拟模式:请切换联调后导出本月新增名单')
+    return delay(undefined)
+  }
+  return real.exportPersonnelNewMonth(params)
+}
+
+export function fetchDistrictAdjustNotice() {
+  if (useMock()) {
+    return delay({ adjusted: false } as real.DistrictAdjustNotice)
+  }
+  return real.fetchDistrictAdjustNotice()
+}
+
+export function submitDistrictYearlyAdjust(payload: {
+  monthly: number
+  funeral: number
+  effectiveMonth?: string
+  reason?: string
+}) {
+  if (useMock()) {
+    ElMessage.info('模拟模式:请切换联调后办理全区调标')
+    return delay({ tip: '模拟已调标' })
+  }
+  return real.submitDistrictYearlyAdjust(payload)
+}
+
 export function fetchPersonnelImportHistory(limit = 50) {
   if (useMock()) {
     return delay([] as real.PersonnelImportHistoryItem[])
@@ -255,12 +310,12 @@ export function ensureVillageMonthBatch(town?: string, village?: string, payMont
   return real.ensureVillageMonthBatch(town, village, payMonth)
 }
 
-export function fetchPersonDetail(personnelId: string) {
+export function fetchPersonDetail(personnelId: string, config?: { silent?: boolean }) {
   if (useMock()) {
     const hit = mockPeople.find((p) => String(p.id) === String(personnelId))
     return delay(hit ? { ...hit } : null)
   }
-  return real.fetchPersonDetail(personnelId)
+  return real.fetchPersonDetail(personnelId, config)
 }
 
 export function savePerson(payload: Partial<Person>) {
@@ -322,6 +377,16 @@ export function updateBatchMembers(batchId: string, personnelIds: string[]) {
   return real.updateBatchMembers(batchId, personnelIds)
 }
 
+/** 测算锁定前增量加人 */
+export function addBatchMembers(batchId: string, personnelIds: string[]) {
+  if (useMock()) {
+    const batch = mockVillageBatches.find((b) => b.id === batchId)
+    if (batch) batch.peopleCount = (batch.peopleCount || 0) + personnelIds.length
+    return delay({ ok: true, added: personnelIds.length, message: '' })
+  }
+  return real.addBatchMembers(batchId, personnelIds)
+}
+
 /** 批次已纳入人员 ID */
 export function fetchBatchMemberIds(batchId: string) {
   if (useMock()) {
@@ -798,6 +863,8 @@ export function fetchApprovals(params?: {
   /** 业务类型包含关键字,如「月度批次」 */
   bizTypeIncludes?: string
   status?: string
+  /** 享受起始年月 yyyyMM,镇经发月名单 */
+  enjoyStartMonth?: string
   pageNum?: number
   pageSize?: number
 }) {

+ 52 - 10
src/api/sjnmtybt/mappers.ts

@@ -1,7 +1,7 @@
 /**
  * 后端 VO ↔ 新前端页面展示模型映射(不新增页面字段,只做值转换)
  */
-import { labelOf, processActionLabelOf, resultLabelOf } from '@/utils/statusLabels'
+import { labelOf, processActionLabelOf, resultLabelOf, sanitizeBizCodesInText } from '@/utils/statusLabels'
 import type {
   AlertItem,
   AlertLifecycleItem,
@@ -207,10 +207,6 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
   } else if (remarksRaw.includes('有土地') || remarksRaw.includes('资格已核验')) {
     retireQualifyType = '有土地退养'
   }
-  // 待提交资格尚未生效,不展示上次通过留下的有地/无地
-  if (biz === 'DRAFT' && retireQualifyType !== '无资格') {
-    retireQualifyType = undefined
-  }
   // 人员状态一律跟 bizStatus,各页一致;退养资格用 retireQualifyType 单独展示
   // 仅「无资格」在业务上等同异常终态,统一展示为「无资格」
   if (retireQualifyType === '无资格') {
@@ -230,6 +226,11 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     bankCard: String(raw.bankCardNumber || ''),
     bankName: String(raw.bankName || ''),
     socialInsuranceCode: raw.socialInsuranceCode ? String(raw.socialInsuranceCode) : undefined,
+    localOrRemote: (() => {
+      const v = String(raw.localOrRemote || '').trim()
+      return v === '异地' || v === '同城' ? v : undefined
+    })(),
+    deathWarning: raw.deathWarning === true || raw.deathWarning === 'true',
     // 月补只认街镇标准表关联结果;无标准时勿回退 0/陈旧冗余值
     standard:
       raw.monthlyStandard == null || raw.monthlyStandard === ''
@@ -239,11 +240,18 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     status,
     source: String(raw.source || ''),
     insuranceStatus: INSURANCE_LABEL[ins] || String(raw.insuranceStatus || '未匹配'),
+    insuranceReason: (() => {
+      const text = String(raw.insuranceStatus || '').trim()
+      if (!text || INSURANCE_LABEL[text] || text === String(raw.insuranceMatchResult || '')) return ''
+      if (text === '匹配通过' || text === '未匹配') return ''
+      return text
+    })(),
     remark: remarks || undefined,
     remarkRaw: remarksRaw.trim() || undefined,
     retireQualifyType,
     phoneNumber: raw.phoneNumber != null ? String(raw.phoneNumber) : undefined,
     address: raw.address != null ? String(raw.address) : undefined,
+    householdAddress: raw.householdAddress != null ? String(raw.householdAddress) : 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,
@@ -271,12 +279,34 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
           raw.payThisMonth === 1 ||
           raw.payThisMonth === '1',
     inputVillageId: raw.inputVillageId != null ? String(raw.inputVillageId) : undefined,
+    deathStatus: raw.deathStatus != null ? String(raw.deathStatus) : 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,
+    personType: raw.personType != null ? String(raw.personType) : undefined,
+    urbanMedicareConfirm: raw.urbanMedicareConfirm != null ? String(raw.urbanMedicareConfirm) : undefined,
+    householdDistrict: raw.householdDistrict != null ? String(raw.householdDistrict) : undefined,
+    householdTown: raw.householdTown != null ? String(raw.householdTown) : undefined,
+    householdVillage: raw.householdVillage != null ? String(raw.householdVillage) : undefined,
+    contactName: raw.contactName != null ? String(raw.contactName) : undefined,
+    contactPhone: raw.contactPhone != null ? String(raw.contactPhone) : undefined,
+    insuranceJoinDate: raw.insuranceJoinDate != null ? String(raw.insuranceJoinDate).slice(0, 10) : undefined,
+    personMark: raw.personMark != null ? String(raw.personMark) : undefined,
+    changeReason: raw.changeReason != null ? String(raw.changeReason) : undefined,
+    benefitApplyDate: raw.benefitApplyDate != null ? String(raw.benefitApplyDate).slice(0, 10) : undefined,
+    sentenceStart: raw.sentenceStart != null ? String(raw.sentenceStart).slice(0, 10) : undefined,
+    sentenceEnd: raw.sentenceEnd != null ? String(raw.sentenceEnd).slice(0, 10) : undefined,
+    cancelApplyDate: raw.cancelApplyDate != null ? String(raw.cancelApplyDate).slice(0, 10) : undefined,
+    cancelReason: raw.cancelReason != null ? String(raw.cancelReason) : undefined,
+    cancelDeathMonth: raw.cancelDeathMonth != null ? String(raw.cancelDeathMonth) : undefined,
+    hasHeir: raw.hasHeir != null ? String(raw.hasHeir) : undefined,
+    heirIdType: raw.heirIdType != null ? String(raw.heirIdType) : undefined,
+    heirBankName: raw.heirBankName != null ? String(raw.heirBankName) : undefined,
+    heirHouseholdAddress: raw.heirHouseholdAddress != null ? String(raw.heirHouseholdAddress) : undefined,
+    heirAddress: raw.heirAddress != null ? String(raw.heirAddress) : undefined,
     funeralDiffAmount: numOrU(raw.funeralDiffAmount),
     landSubsidyIncrease: numOrU(raw.landSubsidyIncrease),
     createTime: fmtTime(raw.createTime as number | string) || undefined,
@@ -552,16 +582,26 @@ export function mapAlert(raw: Record<string, unknown>): AlertItem {
     if (close && !close.fileUrls) close.fileUrls = fileUrls
     else if (!close && !lifecycle[0].fileUrls) lifecycle[0].fileUrls = fileUrls
   }
+  const typeLabel = ALERT_TYPE_LABEL[typeCode] || typeCode
+  const batchNo = alertBatchLabel(raw)
+  const rawPid = raw.personnelId ? String(raw.personnelId).trim() : ''
+  const rawName = String(raw.personnelName || '').trim()
+  const batchLevel = typeCode === 'APPROVAL_TIMEOUT' || typeLabel === '审批超时'
+  const looksBatchToken = (s: string) =>
+    !s ||
+    s === batchNo ||
+    /^(TB|DB|VB|MB)/i.test(s) ||
+    /^\d{6}$/.test(s)
   return {
     id: String(raw.id || ''),
-    batchNo: alertBatchLabel(raw),
-    personName: String(raw.personnelName || ''),
-    personnelId: raw.personnelId ? String(raw.personnelId) : undefined,
+    batchNo,
+    personName: batchLevel || looksBatchToken(rawName) ? '' : rawName,
+    personnelId: batchLevel || looksBatchToken(rawPid) ? undefined : rawPid || undefined,
     town: regionName(String(raw.townId || '')),
     village: regionName(String(raw.villageId || '')),
     type: ALERT_TYPE_LABEL[typeCode] || typeCode,
     level: ALERT_LEVEL_LABEL[level] || level,
-    content: String(raw.content || ''),
+    content: sanitizeBizCodesInText(String(raw.content || '')),
     status: ALERT_STATUS_LABEL[hs] || hs || '待处理',
     result: raw.handleResult ? String(raw.handleResult) : undefined,
     createdAt: fmtTime(raw.createTime as number),
@@ -634,7 +674,9 @@ export function mapStandard(raw: Record<string, unknown>): SubsidyStandard {
   return {
     id: idNum,
     rawId: idStr || undefined,
-    town: String(raw.townName || regionName(String(raw.townId || ''))),
+    town: String(raw.townId) === 'T_DISTRICT' || raw.townName === '全区'
+      ? '全区'
+      : 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),

+ 428 - 42
src/api/sjnmtybt/realBiz.ts

@@ -14,7 +14,12 @@ import {
   TOWN_REVIEW_TIPS,
   VILLAGE_WORKBENCH_TIPS,
 } from '@/utils/buildDashboard'
-import { batchStatusLabel, processActionLabelOf, roleLabelOf } from '@/utils/statusLabels'
+import {
+  batchStatusLabel,
+  personnelStatusLabel,
+  processActionLabelOf,
+  roleLabelOf,
+} from '@/utils/statusLabels'
 import type {
   AccountItem,
   AlertItem,
@@ -142,6 +147,47 @@ function seriesToHalfYearCharts(series: MonthStatRow[] | undefined, months = 6)
   }
 }
 
+/** 当前发放年 1 月~当前月;缺月补 0,不混在册人数 */
+function seriesToYearMonthCharts(series: MonthStatRow[] | undefined, payMonth: string) {
+  const ym = String(payMonth || '').replace('-', '')
+  const year = ym.slice(0, 4)
+  const endMonth = Math.min(12, Math.max(1, Number(ym.slice(4, 6) || 12)))
+  const byMonth = new Map<string, MonthStatRow>()
+  for (const m of series || []) {
+    const key = String(m.payMonth || '').replace('-', '')
+    if (key) byMonth.set(key, m)
+  }
+  const people: { name: string; value: number }[] = []
+  const amount: { name: string; value: number }[] = []
+  for (let i = 1; i <= endMonth; i++) {
+    const key = `${year}${String(i).padStart(2, '0')}`
+    const row = byMonth.get(key)
+    const name = `${year}-${String(i).padStart(2, '0')}`
+    people.push({ name, value: Number(row?.peopleCount || 0) })
+    amount.push({ name, value: Number(row?.totalAmount || 0) })
+  }
+  return { people, amount }
+}
+
+/** 近 3 个自然年:同年各月发放人数/金额相加,缺口年 0 */
+function seriesToYearCharts(series: MonthStatRow[] | undefined, payMonth: string) {
+  const endYear = Number(String(payMonth || '').replace('-', '').slice(0, 4)) || new Date().getFullYear()
+  const years = [endYear - 2, endYear - 1, endYear]
+  const agg = new Map<number, { people: number; amount: number }>()
+  for (const y of years) agg.set(y, { people: 0, amount: 0 })
+  for (const m of series || []) {
+    const y = Number(String(m.payMonth || '').replace('-', '').slice(0, 4))
+    const row = agg.get(y)
+    if (!row) continue
+    row.people += Number(m.peopleCount || 0)
+    row.amount += Number(m.totalAmount || 0)
+  }
+  return {
+    people: years.map((y) => ({ name: `${y}年`, value: agg.get(y)?.people || 0 })),
+    amount: years.map((y) => ({ name: `${y}年`, value: agg.get(y)?.amount || 0 })),
+  }
+}
+
 /** 本批次各村发放人数/金额(镇级工作台) */
 async function buildTownVillageChartsFromApi(townId: string, payMonth: string) {
   await ensureVillages(townId)
@@ -193,6 +239,10 @@ function emptyDashboard(role: ReturnType<typeof getAppConfig>['roles'][0], payMo
     statusPieYear: [],
     todos: [],
     recentAlerts: [],
+    monthPeopleChart: [],
+    monthAmountChart: [],
+    yearPeopleChart: [],
+    yearAmountChart: [],
     villageTips: role.village ? VILLAGE_WORKBENCH_TIPS : undefined,
     districtOverview: isDistrict
       ? {
@@ -247,6 +297,7 @@ type WorkbenchApi = {
   }>
   villageCharts?: Array<{ villageId?: string; peopleCount?: number; amount?: number | string }>
   batchStatusCounts?: Record<string, number>
+  personnelStatusCounts?: Record<string, number>
   recentAlerts?: Record<string, unknown>[]
   monthBatches?: Record<string, unknown>[]
   todos?: Array<{
@@ -356,6 +407,7 @@ async function loadWorkbenchApi(
   villageId?: string,
   months = 6,
   roleKey?: string,
+  skipPaymentScope = false,
 ): Promise<WorkbenchApi | null> {
   const wb = await get<WorkbenchApi>(
     '/dashboard/workbench',
@@ -366,6 +418,7 @@ async function loadWorkbenchApi(
       months,
       operator: operatorOf(),
       roleKey: roleKey || getAppConfig().currentRole,
+      skipPaymentScope: skipPaymentScope || undefined,
     },
     { silent: true, timeout: 180_000 },
   ).catch(() => null)
@@ -408,9 +461,39 @@ export async function markWorkbenchTodoRead(todoKey: string) {
 }
 
 /** —— 工作台:优先单接口聚合;旧后台无 workbench 时自动降级 —— */
+export async function fetchTownVillagePayTable(params: {
+  roleKey: string
+  currentMonth: string
+}): Promise<{ people: Array<{ name: string; value: number }>; amount: Array<{ name: string; value: number }> }> {
+  await ensureTowns()
+  const role =
+    getAppConfig().roles.find((r) => r.key === params.roleKey) || getAppConfig().roles[0]
+  const { townId } = await resolveScopeIds(role.town, undefined)
+  if (townId) await ensureVillages(townId)
+  const payMonth = monthToPayMonth(params.currentMonth)
+  const rows = await get<WorkbenchApi['villageCharts']>(
+    '/dashboard/village-charts',
+    { payMonth, townId: townId || undefined },
+    { silent: true, timeout: 180_000 },
+  ).catch(() => null)
+  const list = rows || []
+  return {
+    people: list.map((v) => ({
+      name: regionName(String(v.villageId || '')) || String(v.villageId || '未知村'),
+      value: Number(v.peopleCount || 0),
+    })),
+    amount: list.map((v) => ({
+      name: regionName(String(v.villageId || '')) || String(v.villageId || '未知村'),
+      value: Number(v.amount || 0),
+    })),
+  }
+}
+
 export async function fetchDashboard(params: {
   roleKey: string
   currentMonth: string
+  /** 首屏跳过发放明细全量,村表再补一次 */
+  light?: boolean
 }): Promise<DashboardData> {
   await ensureTowns()
   const role =
@@ -434,8 +517,9 @@ export async function fetchDashboard(params: {
     payMonth,
     townId || undefined,
     villageId || undefined,
-    6,
+    12,
     params.roleKey,
+    !!params.light,
   )
 
   if (!wb) return base
@@ -443,16 +527,45 @@ export async function fetchDashboard(params: {
   const summary = wb.summary
   const region = wb.region
   const halfYear = seriesToHalfYearCharts(region?.series, 6)
+  const yearMonth = seriesToYearMonthCharts(region?.series, payMonth)
+  const yearTotal = seriesToYearCharts(region?.series, payMonth)
+  base.monthPeopleChart = yearMonth.people
+  base.monthAmountChart = yearMonth.amount
+  base.yearPeopleChart = yearTotal.people
+  base.yearAmountChart = yearTotal.amount
   let regionPeople = Number(region?.currentMonth?.peopleCount || 0)
   let regionAmount = Number(region?.currentMonth?.totalAmount || 0)
   if (regionPeople <= 0) regionPeople = Number(wb.monthPeopleCount || 0)
   if (regionAmount <= 0) regionAmount = Number(wb.monthAmount || 0)
+  // 村合作社:发放明细挂全区/镇批,村粒度 region 常为 0;与镇展板各村表同源回切本村
+  if (
+    (role.key === 'village' || role.village) &&
+    townId &&
+    villageId &&
+    regionPeople <= 0
+  ) {
+    const townWb = await loadWorkbenchApi(payMonth, townId, undefined, 12, 'town', true)
+    const row = (townWb?.villageCharts || []).find((v) => {
+      const id = String(v.villageId || '')
+      const name = regionName(id)
+      return id === villageId || id === role.village || name === role.village || name === regionName(villageId)
+    })
+    if (row) {
+      regionPeople = Number(row.peopleCount || 0)
+      regionAmount = Number(row.amount || 0)
+    }
+  }
   // 发放人数/金额禁止回退到在册人数或镇级汇总,避免村级卡片 1367 人配上被截断的 53 万
   if (halfYear.people.length) {
     const lastIdx = halfYear.people.length - 1
     if (regionPeople > 0) halfYear.people[lastIdx].value = regionPeople
     if (regionAmount > 0) halfYear.amount[lastIdx].value = regionAmount
   }
+  if (yearMonth.people.length) {
+    const lastIdx = yearMonth.people.length - 1
+    if (regionPeople > 0) yearMonth.people[lastIdx].value = regionPeople
+    if (regionAmount > 0) yearMonth.amount[lastIdx].value = regionAmount
+  }
 
   const pendingAlert = Number(summary?.pendingAlertCount || 0)
   const pendingTown = Number(wb.pendingTownApproveCount || 0)
@@ -471,15 +584,25 @@ export async function fetchDashboard(params: {
   base.batchInfo.alertCount = pendingAlert
 
   const monthBatches = (wb.monthBatches || []).map(mapVillageBatch)
-  const statusCounts = wb.batchStatusCounts || {}
+  const personnelCounts = wb.personnelStatusCounts || {}
+  const statusCounts = Object.keys(personnelCounts).length
+    ? personnelCounts
+    : wb.batchStatusCounts || {}
+  const usePersonnelPie = Object.keys(personnelCounts).length > 0
   base.statusPieMonth = Object.entries(statusCounts)
     .map(([code, value]) => ({
-      name: batchStatusLabel(code),
+      name: usePersonnelPie ? personnelStatusLabel(code) || code : batchStatusLabel(code),
       value: Number(value) || 0,
     }))
     .filter((i) => i.value > 0)
   base.statusPieYear = base.statusPieMonth
-  base.recentAlerts = (wb.recentAlerts || []).map(mapAlert)
+  base.recentAlerts = (wb.recentAlerts || [])
+    .map(mapAlert)
+    .filter((a) => {
+      if (role.key === 'village') return a.type === '退养资格驳回'
+      if (role.key === 'town') return a.type === '审批超时'
+      return true
+    })
   const apiTodos = (wb.todos || []).map((t) => ({
     id: String(t.id || ''),
     type: String(t.type || '待办'),
@@ -743,6 +866,12 @@ export type PeoplePageParams = {
   bizStatusIn?: string
   /** 非本村视角隐藏他村 DRAFT */
   hideDraftUnlessVillage?: boolean
+  /** 名单月份 yyyyMM,新增名单导出标题用 */
+  payMonth?: string
+  /** 享受起始年月 yyyyMM,镇经发月名单只收本月 */
+  enjoyStartMonth?: string
+  /** 村级导出不带银行卡、开户行 */
+  omitBank?: boolean
   pageNum?: number
   pageSize?: number
 }
@@ -780,6 +909,7 @@ export async function fetchPeoplePage(
     bizStatusIn: params?.bizStatusIn || undefined,
     hideDraftUnlessVillage: params?.hideDraftUnlessVillage ? true : undefined,
     payMonth: params?.batchNo || undefined,
+    enjoyStartMonth: params?.enjoyStartMonth || undefined,
   }, { timeout: 90_000 })
   const list = (res?.list || []).map((p) => mapPerson(p, params?.batchNo || currentPayMonth()))
   return {
@@ -904,6 +1034,115 @@ export async function exportPersonnelImportFailRows(failRows: PersonnelImportFai
   URL.revokeObjectURL(url)
 }
 
+async function downloadPostBlob(
+  url: string,
+  body: Record<string, unknown>,
+  filename: string,
+) {
+  const res = await service.post(url, body, {
+    responseType: 'blob',
+    timeout: 180000,
+  })
+  const blob = res.data as Blob
+  if (blob && blob.type && blob.type.includes('application/json')) {
+    const text = await blob.text()
+    let msg = '导出失败'
+    try {
+      const j = JSON.parse(text) as { message?: string; msg?: string }
+      msg = j.message || j.msg || msg
+    } catch {
+      /* ignore */
+    }
+    throw new Error(msg)
+  }
+  const href = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = href
+  a.download = filename
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+  URL.revokeObjectURL(href)
+}
+
+async function peopleExportScope(params?: PeoplePageParams) {
+  const cfg = getAppConfig()
+  const role = cfg.roles.find((r) => r.key === cfg.currentRole)
+  const town = params?.town || role?.town
+  const village = params?.village || role?.village
+  const { townId, villageId } = await resolveScopeIds(town, village)
+  return {
+    townId: townId || undefined,
+    villageId: villageId || undefined,
+    name: params?.name || undefined,
+    idNumber: params?.idCard || undefined,
+    phoneNumber: params?.phone || undefined,
+    bizStatus: params?.bizStatus || undefined,
+    insuranceMatchResult: params?.insuranceMatchResult || undefined,
+    retireQualifyType:
+      params?.retireQualifyType && params.retireQualifyType !== 'UNSET'
+        ? params.retireQualifyType
+        : undefined,
+    source: params?.source || undefined,
+    bizStatusNotIn: params?.bizStatusNotIn || undefined,
+    bizStatusIn: params?.bizStatusIn || undefined,
+    hideDraftUnlessVillage: params?.hideDraftUnlessVillage ? true : undefined,
+    payMonth: params?.payMonth || undefined,
+    enjoyStartMonth: params?.enjoyStartMonth || params?.payMonth || undefined,
+    omitBank: params?.omitBank ? true : undefined,
+  }
+}
+
+export async function exportPersonnelRoster(params?: PeoplePageParams) {
+  const village = params?.village || getAppConfig().roles.find((r) => r.key === getAppConfig().currentRole)?.village || ''
+  await downloadPostBlob(
+    '/personnel/export-roster',
+    await peopleExportScope(params),
+    village ? `${village}人员列表.csv` : '本村人员列表.csv',
+  )
+}
+
+export async function exportPersonnelLedger(params?: PeoplePageParams) {
+  await downloadPostBlob('/personnel/export-ledger', await peopleExportScope(params), '人员台账.csv')
+}
+
+export async function exportPersonnelNewMonth(params?: PeoplePageParams) {
+  const town = params?.town || getAppConfig().roles.find((r) => r.key === getAppConfig().currentRole)?.town || ''
+  await downloadPostBlob(
+    '/personnel/export-new-month',
+    await peopleExportScope(params),
+    `松江区${town}老年农民享受土地退养补助金新增名单.xlsx`,
+  )
+}
+
+export type DistrictAdjustNotice = {
+  adjusted?: boolean
+  year?: string
+  adjustTime?: number
+  message?: string
+}
+
+export async function fetchDistrictAdjustNotice(): Promise<DistrictAdjustNotice> {
+  const raw = await get<DistrictAdjustNotice>('/amount-standards/adjust-notice')
+  return raw || { adjusted: false }
+}
+
+export async function submitDistrictYearlyAdjust(payload: {
+  monthly: number
+  funeral: number
+  effectiveMonth?: string
+  reason?: string
+}) {
+  return post<Record<string, unknown>>('/amount-standards/district-yearly-adjust', {
+    monthlyAmount: payload.monthly,
+    funeralAmount: payload.funeral,
+    effectiveMonth: payload.effectiveMonth,
+    adjustReason: payload.reason,
+    operator: operatorOf(),
+    districtWide: true,
+  })
+}
+
 export async function fetchPersonnelImportHistory(limit = 50): Promise<PersonnelImportHistoryItem[]> {
   const list = await get<PersonnelImportHistoryItem[]>('/personnel/import/history', { limit })
   return Array.isArray(list) ? list : []
@@ -920,12 +1159,17 @@ export async function fetchAlertDetail(alertId: string): Promise<AlertItem | nul
   }
 }
 
-export async function fetchPersonDetail(personnelId: string): Promise<Person | null> {
+export async function fetchPersonDetail(
+  personnelId: string,
+  config?: { silent?: boolean },
+): Promise<Person | null> {
   if (!personnelId) return null
   await ensureTowns()
   try {
     const raw = await get<Record<string, unknown>>(
       `/personnel/${encodeURIComponent(personnelId)}`,
+      undefined,
+      { silent: config?.silent },
     )
     return raw ? mapPerson(raw) : null
   } catch {
@@ -957,9 +1201,28 @@ export async function savePerson(payload: Partial<Person>) {
       bankType: blank(payload.bankType),
       bankBranchCode: blank(payload.bankBranchCode),
       socialInsuranceCode: blank(payload.socialInsuranceCode),
+      localOrRemote: blank(payload.localOrRemote),
+      retireQualifyType: qualifyTypeCode(payload.retireQualifyType),
       phoneNumber: blank(payload.phoneNumber),
       address: blank(payload.address),
+      householdAddress: blank(payload.householdAddress),
       householdType: blank(payload.householdType),
+      personType: blank(payload.personType),
+      urbanMedicareConfirm: blank(payload.urbanMedicareConfirm),
+      householdDistrict: blank(payload.householdDistrict),
+      householdTown: blank(payload.householdTown),
+      householdVillage: blank(payload.householdVillage),
+      contactName: blank(payload.contactName),
+      contactPhone: blank(payload.contactPhone),
+      insuranceJoinDate: blank(payload.insuranceJoinDate),
+      personMark: blank(payload.personMark),
+      benefitApplyDate: blank(payload.benefitApplyDate),
+      heirIdType: isEdit ? String(payload.heirIdType ?? '').trim() : blank(payload.heirIdType),
+      heirBankName: isEdit ? String(payload.heirBankName ?? '').trim() : blank(payload.heirBankName),
+      heirHouseholdAddress: isEdit
+        ? String(payload.heirHouseholdAddress ?? '').trim()
+        : blank(payload.heirHouseholdAddress),
+      heirAddress: isEdit ? String(payload.heirAddress ?? '').trim() : blank(payload.heirAddress),
       villageCode: blank(payload.villageCode),
       entrustPlanCode: blank(payload.entrustPlanCode),
       entrustPlanName: blank(payload.entrustPlanName),
@@ -974,6 +1237,7 @@ export async function savePerson(payload: Partial<Person>) {
       nextPayStartMonth: monthToPayMonth(payload.nextPayStartMonth) || undefined,
       payeeName: blank(payload.payeeName) || blank(payload.name),
       deathDate: isEdit ? String(payload.deathDate ?? '').trim() : blank(payload.deathDate),
+      deathStatus: isEdit ? String(payload.deathStatus ?? '').trim() : blank(payload.deathStatus),
       heirName: isEdit ? String(payload.heirName ?? '').trim() : blank(payload.heirName),
       heirRelation: isEdit ? String(payload.heirRelation ?? '').trim() : blank(payload.heirRelation),
       heirIdNumber: isEdit ? String(payload.heirIdNumber ?? '').trim() : blank(payload.heirIdNumber),
@@ -1064,6 +1328,7 @@ export async function reviewRetireQualify(payload: {
     fileUrls: payload.fileUrls,
     operator: operatorOf(),
   })
+  clearGetCache()
   return { ok: true }
 }
 
@@ -1082,6 +1347,7 @@ export async function reviewRetireQualifyBatch(payload: {
     fileUrls: payload.fileUrls,
     operator: operatorOf(),
   }, { timeout: 180_000 })
+  clearGetCache()
   return {
     ok: res?.ok !== false,
     message: String(res?.message || ''),
@@ -1138,6 +1404,19 @@ export async function updateBatchMembers(batchId: string, personnelIds: string[]
   return { ok: true }
 }
 
+/** 测算锁定前向发放批增量加人(镇统计批自动改挂全区父批) */
+export async function addBatchMembers(batchId: string, personnelIds: string[]) {
+  const res = await post<Record<string, unknown>>(
+    `/payments/batches/${encodeURIComponent(batchId)}/add-members`,
+    {
+      personnelIds,
+      operator: operatorOf(),
+    },
+    { timeout: 60000 },
+  )
+  return { ok: true, added: Number(res?.added || 0), message: String(res?.message || '') }
+}
+
 /** 批次已纳入人员 ID(发放明细) */
 export async function fetchBatchMemberIds(batchId: string): Promise<string[]> {
   if (!batchId) return []
@@ -1385,19 +1664,23 @@ export async function fetchBatchMemberPeoplePage(
   const { townId, villageId } = await resolveScopeIds(town, villageKey)
   const queryBatchId = opts?.parentBatchId || batchId
 
-  const detailsRes = await post<PageResult<Record<string, unknown>>>('/payments/details/page', {
-    pageNum,
-    pageSize,
-    batchId: queryBatchId,
-    townId: townId || undefined,
-    villageId: villageId || undefined,
-    name: opts?.name?.trim() || undefined,
-    idNumber: opts?.idCard?.trim() || undefined,
-    sortBy: opts?.sortBy || undefined,
-    sortOrder: opts?.sortOrder || undefined,
-    onlyFuneral: opts?.onlyFuneral || undefined,
-    onlySupplement: opts?.onlySupplement || undefined,
-  })
+  const detailsRes = await post<PageResult<Record<string, unknown>>>(
+    '/payments/details/page',
+    {
+      pageNum,
+      pageSize,
+      batchId: queryBatchId,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      name: opts?.name?.trim() || undefined,
+      idNumber: opts?.idCard?.trim() || undefined,
+      sortBy: opts?.sortBy || undefined,
+      sortOrder: opts?.sortOrder || undefined,
+      onlyFuneral: opts?.onlyFuneral || undefined,
+      onlySupplement: opts?.onlySupplement || undefined,
+    },
+    { timeout: 60_000 },
+  )
   const details = detailsRes?.list || []
   const batchNo = opts?.batchNo || currentPayMonth()
   const result: BatchMemberPerson[] = []
@@ -1848,6 +2131,54 @@ function toTownApproval(vb: VillageBatch, status: string): Approval {
   }
 }
 
+async function fillQualifyInsurance(list: Approval[]) {
+  const ids = list
+    .filter((r) => {
+      const t = String(r.insuranceStatus || '')
+      return r.personnelId && (t === '' || t === '未匹配')
+    })
+    .map((r) => String(r.personnelId))
+  if (!ids.length) return
+  const rows = await post<Array<{ personnelId?: string; matchResult?: string; reason?: string }>>(
+    '/personnel/qualify-insurance-match',
+    { personnelIds: ids },
+    { silent: true },
+  ).catch(() => null)
+  const byId = new Map((rows || []).map((x) => [String(x.personnelId || ''), x]))
+  for (const row of list) {
+    const hit = byId.get(String(row.personnelId || ''))
+    if (!hit?.reason) continue
+    row.insuranceStatus = hit.reason
+    row.insuranceReason = hit.reason === '匹配通过' ? '' : hit.reason
+  }
+}
+
+async function attachQualifySubmitTime(list: Approval[], townId?: string) {
+  if (!list.length) return
+  const logs = await post<PageResult<Record<string, unknown>>>(
+    '/process-logs/page',
+    {
+      pageNum: 1,
+      pageSize: 500,
+      townId: townId || undefined,
+      bizType: 'RETIRE_QUALIFY',
+      action: 'QUALIFY_SUBMIT',
+    },
+  ).catch(() => null)
+  const times = new Map<string, string>()
+  for (const raw of logs?.list || []) {
+    const pid = String(raw.personnelId || '')
+    const when = mapProcessLog(raw).operateTime
+    if (!pid || !when) continue
+    const prev = times.get(pid)
+    if (!prev || when > prev) times.set(pid, when)
+  }
+  for (const row of list) {
+    const pid = String(row.personnelId || '')
+    row.addedAt = times.get(pid) || row.createdAt || ''
+  }
+}
+
 function toQualifyApproval(p: Person, status: string): Approval {
   return {
     id: approvalIdQualify(String(p.id)),
@@ -1864,6 +2195,15 @@ function toQualifyApproval(p: Person, status: string): Approval {
     personName: p.name,
     personnelId: String(p.id),
     idCard: p.idCard,
+    householdType: p.householdType,
+    startMonth: p.startMonth,
+    standard: p.standard,
+    remark: p.remark,
+    retireQualifyType: p.retireQualifyType,
+    insuranceStatus: p.insuranceReason || p.insuranceStatus || '未匹配',
+    insuranceReason: p.insuranceReason,
+    deathWarning: p.deathWarning,
+    deathStatus: p.deathStatus,
   }
 }
 
@@ -1895,6 +2235,7 @@ export async function fetchApprovals(params?: {
   pageNum?: number
   pageSize?: number
   status?: string
+  enjoyStartMonth?: string
 }): Promise<{ list: Approval[]; total: number; pageNum: number; pageSize: number }> {
   await ensureTowns()
   const { townId, villageId } = await resolveScopeIds(params?.town, params?.village)
@@ -1911,11 +2252,15 @@ export async function fetchApprovals(params?: {
         town: params?.town,
         village: params?.village,
         bizStatus: 'PENDING_TOWN_APPROVE',
+        enjoyStartMonth: params?.enjoyStartMonth,
         pageNum,
         pageSize,
       })
+      const list = people.list.map((p) => toQualifyApproval(p, '待审批'))
+      await attachQualifySubmitTime(list, townId)
+      await fillQualifyInsurance(list)
       return {
-        list: people.list.map((p) => toQualifyApproval(p, '待审批')),
+        list,
         total: people.total,
         pageNum,
         pageSize,
@@ -1928,16 +2273,17 @@ export async function fetchApprovals(params?: {
         : status === '已驳回'
           ? 'QUALIFY_REJECT'
           : 'QUALIFY_PASS,QUALIFY_REJECT'
+    const rosterMonth = String(params?.enjoyStartMonth || '').replace(/-/g, '')
     const logs = await post<PageResult<Record<string, unknown>>>('/process-logs/page', {
-      pageNum,
-      pageSize,
+      pageNum: rosterMonth ? 1 : pageNum,
+      pageSize: rosterMonth ? 200 : pageSize,
       townId: townId || undefined,
       villageId: villageId || undefined,
       bizType: 'RETIRE_QUALIFY',
       action,
     })
     const rows = logs?.list || []
-    const list: Approval[] = []
+    const built: Approval[] = []
     await Promise.all(
       rows.map(async (log) => {
         const pid = String(log.personnelId || '')
@@ -1953,9 +2299,27 @@ export async function fetchApprovals(params?: {
         )
         const mapped = mapProcessLog(log)
         if (mapped.operateTime) row.createdAt = mapped.operateTime
-        list.push(row)
+        built.push(row)
       }),
     )
+    const seen = new Set<string>()
+    const list = built.filter((row) => {
+      if (rosterMonth) {
+        const start = String(row.startMonth || '').replace(/-/g, '')
+        if (!start.startsWith(rosterMonth)) return false
+      }
+      const key = String(row.personnelId || row.id || '')
+      if (!key || seen.has(key)) return false
+      seen.add(key)
+      return true
+    })
+    if (rosterMonth) {
+      const from = (pageNum - 1) * pageSize
+      const pageList = list.slice(from, from + pageSize)
+      await attachQualifySubmitTime(pageList, townId)
+      return { list: pageList, total: list.length, pageNum, pageSize }
+    }
+    await attachQualifySubmitTime(list, townId)
     return { list, total: Number(logs?.total || list.length), pageNum, pageSize }
   }
 
@@ -2205,6 +2569,7 @@ export async function decideApprovals(payload: {
       retireQualifyType: payload.retireQualifyType,
     })
   }
+  clearGetCache()
   return { ok: true }
 }
 
@@ -2225,6 +2590,7 @@ export async function decideApproval(payload: {
       opinion: payload.opinion,
       fileUrls: payload.fileUrls,
     })
+    clearGetCache()
     return { ok: true }
   }
   const body = {
@@ -2253,12 +2619,9 @@ export async function fetchStatusBatches(): Promise<VillageBatch[]> {
 
 const standardIdMap = new Map<number, string>()
 
-export async function fetchStandards(town?: string): Promise<SubsidyStandard[]> {
+export async function fetchStandards(_town?: string): Promise<SubsidyStandard[]> {
   await ensureTowns()
-  const townId = town ? townIdOf(town) : undefined
-  // 后端返回 PageResult,不是裸数组
   const page = await get<PageResult<Record<string, unknown>>>('/amount-standards', {
-    townId: townId || undefined,
     pageNum: 1,
     pageSize: 200,
   })
@@ -2272,15 +2635,11 @@ export async function fetchStandards(town?: string): Promise<SubsidyStandard[]>
 }
 
 /** 本镇当前有效丧葬补贴标准(元);优先 /amount-standards/current,失败则回退标准列表 */
-export async function fetchCurrentFuneralAmount(town: string): Promise<number | null> {
-  if (!town) return null
-  await ensureTowns()
-  const townId = townIdOf(town)
-  if (!townId) return null
+export async function fetchCurrentFuneralAmount(_town?: string): Promise<number | null> {
   try {
     const raw = await get<Record<string, unknown>>(
       '/amount-standards/current',
-      { townId, standardType: 'FUNERAL' },
+      { standardType: 'FUNERAL' },
       { silent: true },
     )
     const amt = Number(raw?.funeralAmount ?? raw?.amount ?? 0)
@@ -2289,7 +2648,7 @@ export async function fetchCurrentFuneralAmount(town: string): Promise<number |
     /* 回退列表 */
   }
   try {
-    const list = await fetchStandards(town)
+    const list = await fetchStandards()
     const cur = list.find((s) => s.current && s.funeral > 0) || list.find((s) => s.funeral > 0)
     return cur && cur.funeral > 0 ? cur.funeral : null
   } catch {
@@ -2319,17 +2678,14 @@ export async function createStandard(payload: {
   date: string
   reason: string
 }) {
-  const townId = townIdOf(payload.town)
-  if (!townId) return { ok: false as const, message: '未识别街镇' }
   let effectiveTime: number
   try {
     effectiveTime = dateToEpochMs(payload.date)
   } catch {
     return { ok: false as const, message: '请选择有效的生效日期' }
   }
-  // 新增标准只落库,不走 yearly-adjust(那会生成调标补发批)
   const saved = await post<Record<string, unknown>>('/amount-standards', {
-    townId,
+    townId: 'T_DISTRICT',
     standardType: 'YEARLY_ADJUST',
     amount: payload.monthly,
     funeralAmount: payload.funeral,
@@ -2361,11 +2717,9 @@ export async function updateStandard(payload: {
   } catch {
     return { ok: false as const, message: '请选择有效的生效日期' }
   }
-  const townId = payload.town ? townIdOf(payload.town) : ''
-  if (!townId) return { ok: false as const, message: '未识别街镇' }
   const saved = await post<Record<string, unknown>>('/amount-standards', {
     id: realId,
-    townId,
+    townId: 'T_DISTRICT',
     standardType: 'YEARLY_ADJUST',
     amount: payload.monthly,
     funeralAmount: payload.funeral,
@@ -2580,6 +2934,12 @@ export async function fetchDeaths(params?: {
       heirIdCard: s.heirIdNumber ? String(s.heirIdNumber) : '',
       heirBankCard: s.heirBankCard ? String(s.heirBankCard) : '',
       heirContact: s.heirPhone ? String(s.heirPhone) : '',
+      heirRelation: s.heirRelation ? String(s.heirRelation) : '',
+      heirIdType: s.heirIdType ? String(s.heirIdType) : '',
+      heirBankName: s.heirBankName ? String(s.heirBankName) : '',
+      heirHouseholdAddress: s.heirHouseholdAddress ? String(s.heirHouseholdAddress) : '',
+      heirAddress: s.heirAddress ? String(s.heirAddress) : '',
+      cancelApplyDate: s.cancelApplyDate ? String(s.cancelApplyDate).slice(0, 10) : '',
       source: '社区事务受理中心手工录入',
       status: listStatus,
       createdAt: fmtTimeSafe(s.createTime as number),
@@ -2679,6 +3039,10 @@ export async function createDeathRecord(payload: {
   heirIdCard?: string
   heirBankCard?: string
   heirContact?: string
+  heirIdType?: string
+  heirBankName?: string
+  heirHouseholdAddress?: string
+  heirAddress?: string
   fileUrls?: string
 }) {
   let personnelId = payload.personId ? personIdStr(payload.personId) : ''
@@ -2723,6 +3087,11 @@ export async function createDeathRecord(payload: {
         heirIdNumber: payload.heirIdCard || undefined,
         heirBankCard: payload.heirBankCard || undefined,
         heirPhone: payload.heirContact || undefined,
+        heirIdType: payload.heirIdType || undefined,
+        heirBankName: payload.heirBankName || undefined,
+        heirHouseholdAddress: payload.heirHouseholdAddress || undefined,
+        heirAddress: payload.heirAddress || undefined,
+        hasHeir: payload.heirName ? '是' : undefined,
         source: '死亡登记',
         expectedUpdateTime: Number.isFinite(ut) && ut > 0 ? ut : undefined,
       }).catch(() => undefined)
@@ -2736,6 +3105,11 @@ export async function createDeathRecord(payload: {
       heirIdNumber: payload.heirIdCard,
       heirBankCard: payload.heirBankCard,
       heirPhone: payload.heirContact,
+      heirRelation: payload.heirRelation,
+      heirIdType: payload.heirIdType,
+      heirBankName: payload.heirBankName,
+      heirHouseholdAddress: payload.heirHouseholdAddress,
+      heirAddress: payload.heirAddress,
       fileUrls: payload.fileUrls,
       townId: detail?.townId || townId,
       operator: operatorOf(),
@@ -2756,6 +3130,8 @@ export async function createSpecialBiz(payload: {
   reason: string
   amount: number
   heirName?: string
+  sentenceStart?: string
+  sentenceEnd?: string
   fileUrls?: string
   /** 多发退款方案:DEDUCT_NEXT | RETURN_FISCAL */
   refundMethod?: 'DEDUCT_NEXT' | 'RETURN_FISCAL'
@@ -2793,6 +3169,8 @@ export async function createSpecialBiz(payload: {
     amount: payload.amount,
     townId: townId || undefined,
     heirName: payload.heirName,
+    sentenceStart: payload.sentenceStart,
+    sentenceEnd: payload.sentenceEnd,
     fileUrls: payload.fileUrls,
     refundMethod: bizType === 'REFUND' ? payload.refundMethod || 'DEDUCT_NEXT' : undefined,
     operator: operatorOf(),
@@ -3753,6 +4131,10 @@ export async function archiveToNextMonth(id?: string) {
 export async function fetchAlertsPage(params?: {
   town?: string
   village?: string
+  name?: string
+  type?: string
+  status?: string
+  level?: string
   pageNum?: number
   pageSize?: number
 }): Promise<{ list: AlertItem[]; total: number; pageNum: number; pageSize: number }> {
@@ -3765,6 +4147,10 @@ export async function fetchAlertsPage(params?: {
     pageSize,
     townId: townId || undefined,
     villageId: villageId || undefined,
+    personnelName: params?.name || undefined,
+    type: params?.type || undefined,
+    handleStatus: params?.status || undefined,
+    level: params?.level || undefined,
   })
   return {
     list: (res?.list || []).map(mapAlert),

+ 4 - 0
src/components.d.ts

@@ -60,10 +60,13 @@ declare module 'vue' {
     ElUpload: typeof import('element-plus/es')['ElUpload']
     FlowAttachments: typeof import('./components/biz/FlowAttachments.vue')['default']
     PagePanel: typeof import('./components/common/PagePanel.vue')['default']
+    PersonNameCell: typeof import('./components/common/PersonNameCell.vue')['default']
     PersonnelDetailDescriptions: typeof import('./components/biz/PersonnelDetailDescriptions.vue')['default']
+    PersonnelFieldLabel: typeof import('./components/biz/PersonnelFieldLabel.vue')['default']
     PersonnelFormFields: typeof import('./components/biz/PersonnelFormFields.vue')['default']
     PersonPickerDialog: typeof import('./components/biz/PersonPickerDialog.vue')['default']
     PersonStatusTimeline: typeof import('./components/biz/PersonStatusTimeline.vue')['default']
+    PieMixPanel: typeof import('./components/common/PieMixPanel.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
     SensitiveRevealSwitch: typeof import('./components/common/SensitiveRevealSwitch.vue')['default']
@@ -71,6 +74,7 @@ declare module 'vue' {
     StatCards: typeof import('./components/common/StatCards.vue')['default']
     StatusTag: typeof import('./components/common/StatusTag.vue')['default']
     TablePagination: typeof import('./components/common/TablePagination.vue')['default']
+    WorkbenchTipsPanel: typeof import('./components/common/WorkbenchTipsPanel.vue')['default']
   }
   export interface GlobalDirectives {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']

+ 17 - 3
src/components/biz/AlertLifecycleTimeline.vue

@@ -9,14 +9,14 @@
         :type="nodeType(it)"
       >
         <div class="alert-lifecycle__head">
-          <strong>{{ it.actionLabel || it.action || '办理' }}</strong>
+          <strong>{{ actionText(it) }}</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>
+          <template v-if="it.result"> · {{ resultText(it.result) }}</template>
         </div>
-        <div v-if="it.remark" class="alert-lifecycle__remark">备注:{{ it.remark }}</div>
+        <div v-if="it.remark" class="alert-lifecycle__remark">备注:{{ remarkText(it.remark) }}</div>
         <div v-if="it.fileUrls" class="alert-lifecycle__files">
           <FlowAttachments :file-urls="it.fileUrls" />
         </div>
@@ -30,8 +30,22 @@
 import { computed } from 'vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import FlowAttachments from '@/components/biz/FlowAttachments.vue'
+import { displayRemark, processActionLabelOf, resultLabelOf, sanitizeBizCodesInText } from '@/utils/statusLabels'
 import type { AlertLifecycleItem } from '@/types/biz'
 
+function actionText(it: AlertLifecycleItem) {
+  return processActionLabelOf(it.actionLabel || it.action || '办理')
+}
+
+function resultText(result?: string) {
+  const raw = resultLabelOf(result) || result || ''
+  return sanitizeBizCodesInText(raw)
+}
+
+function remarkText(remark?: string) {
+  return displayRemark(remark)
+}
+
 const props = withDefaults(
   defineProps<{
     lifecycle?: AlertLifecycleItem[] | null

+ 20 - 12
src/components/biz/BatchDetailDrawer.vue

@@ -34,10 +34,10 @@
               {{ formatAmount(batch.supplementAmount) }}
             </el-descriptions-item>
             <el-descriptions-item label="出盘状态">
-              {{ batch.exportStatus || '未出盘' }}
+              {{ displayBizText(batch.exportStatus) === '—' ? '未出盘' : displayBizText(batch.exportStatus) }}
             </el-descriptions-item>
             <el-descriptions-item label="回盘状态">
-              {{ batch.returnStatus || '未回盘' }}
+              {{ displayBizText(batch.returnStatus) === '—' ? '未回盘' : displayBizText(batch.returnStatus) }}
             </el-descriptions-item>
             <el-descriptions-item label="出盘编号" :span="2">
               {{ batch.exportDiskNo || '-' }}
@@ -67,7 +67,7 @@
               <el-step
                 v-for="s in progressSteps"
                 :key="String(s.code)"
-                :title="String(s.label || '')"
+                :title="stepTitle(s)"
                 :status="stepStatus(String(s.state || ''))"
               />
             </el-steps>
@@ -144,7 +144,11 @@
             show-summary
             :summary-method="peopleTableSummary"
           >
-            <el-table-column prop="name" label="姓名" width="80" />
+            <el-table-column label="姓名" width="140">
+              <template #default="{ row }">
+                <PersonNameCell :person="row" />
+              </template>
+            </el-table-column>
             <el-table-column label="身份证" min-width="168">
               <template #default="{ row }">
                 <SensitiveValue
@@ -364,7 +368,7 @@ import {
   uploadBizFile,
 } from '@/api/biz'
 import { actionLabel } from '@/constants/batchActions'
-import { resultLabelOf } from '@/utils/statusLabels'
+import { displayBizText, displayRemark, resultLabelOf } from '@/utils/statusLabels'
 import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { paymentSummaryFromApi, resolveBatchAmountSummary, sumBatchMemberAmounts } from '@/utils/batchAmounts'
@@ -379,6 +383,7 @@ import type {
 } from '@/types/biz'
 import SensitiveValue from '@/components/common/SensitiveValue.vue'
 import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
 
 const userStore = useUserStore()
 const revealSensitive = ref(false)
@@ -505,15 +510,17 @@ function peopleTableSummary(param: {
 }
 
 const drawerTitle = computed(() => {
-  if (!batch.value) return '批次详情'
-  return `批次 ${batch.value.batchNo || batch.value.id}`
+  if (!batch.value?.batchNo) return '批次详情'
+  return `批次 ${batch.value.batchNo}`
 })
 
-const displayRemarks = computed(() => {
-  const r = batch.value?.remarks || ''
-  if (!r) return '-'
-  return r.replace(/^MOCK_SEED_\d+\|?/, '') || '-'
-})
+const displayRemarks = computed(() => displayRemark(batch.value?.remarks))
+
+function stepTitle(s: Record<string, unknown>) {
+  const label = String(s.label || s.name || '')
+  const zh = displayBizText(label)
+  return zh === '—' ? '办理节点' : zh
+}
 
 const actionLabels = computed(() =>
   (overview.value?.availableActions || []).map((code) => ({
@@ -639,6 +646,7 @@ async function load() {
     batch.value = b
     overview.value = o
     progressSteps.value = steps
+    loading.value = false
     if (b) await loadPeopleForBatch(b)
     if (tab.value === 'flow') await reloadFlow(true)
   } finally {

+ 3 - 2
src/components/biz/PersonStatusTimeline.vue

@@ -34,10 +34,10 @@
           {{ statusHint(log) }}
         </div>
         <div v-if="log.opinion" class="person-status-timeline__remark">
-          意见/备注:{{ log.opinion }}
+          意见/备注:{{ displayRemark(log.opinion) }}
         </div>
         <div v-else-if="log.remarks" class="person-status-timeline__remark">
-          备注:{{ log.remarks }}
+          备注:{{ displayRemark(log.remarks) }}
         </div>
         <div v-if="log.fileUrls" class="person-status-timeline__files">
           <span>附件:</span>
@@ -57,6 +57,7 @@
 import { computed, ref, watch } from 'vue'
 import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import {
+  displayRemark,
   processActionLabelOf,
   resultLabelOf,
   roleLabelOf,

+ 19 - 2
src/components/biz/PersonnelDetailDescriptions.vue

@@ -19,9 +19,11 @@
         <el-descriptions-item
           v-for="f in fieldsOf(g.key)"
           :key="f.key"
-          :label="f.label"
           :span="f.span === 2 ? 2 : 1"
         >
+          <template #label>
+            <PersonnelFieldLabel :label="f.label" :tip="f.tip" />
+          </template>
           <template v-if="isStatusField(f.key)">
             <span class="personnel-detail-descriptions__status-cell">
               <StatusTag :status="displayText(f)" />
@@ -37,6 +39,9 @@
               </el-button>
             </span>
           </template>
+          <template v-else-if="f.key === 'name'">
+            <PersonNameCell :person="person" />
+          </template>
           <template v-else-if="f.key === 'payThisMonth'">
             {{ person.payThisMonth == null ? '—' : person.payThisMonth ? '是' : '否' }}
           </template>
@@ -82,6 +87,7 @@
 import { computed, ref, watch } from 'vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import PersonStatusTimeline from '@/components/biz/PersonStatusTimeline.vue'
+import PersonnelFieldLabel from '@/components/biz/PersonnelFieldLabel.vue'
 import { fetchPersonnelTimeline } from '@/api/biz'
 import {
   PERSONNEL_FIELD_GROUPS,
@@ -94,6 +100,10 @@ import { labelOf, personnelStatusLabel } from '@/utils/statusLabels'
 import { isPersonnelBizStatusLog, isPersonnelInsuranceLog, isPersonnelQualifyLog } from '@/utils/personnelStatusLog'
 import type { Person, ProcessLogItem } from '@/types/biz'
 import SensitiveValue from '@/components/common/SensitiveValue.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
+import { useUserStore } from '@/stores/user'
+
+const userStore = useUserStore()
 
 const props = withDefaults(
   defineProps<{
@@ -117,7 +127,7 @@ type LogFilter = 'all' | 'biz' | 'insurance' | 'qualify'
 const STATUS_LOG_KEYS = new Set(['insuranceStatus', 'status', 'retireQualifyType'])
 
 const fields = computed(() =>
-  visiblePersonnelFields().filter((f) => !f.detailHidden),
+  visiblePersonnelFields({ role: userStore.currentRole, mode: 'detail' }),
 )
 const groups = computed(() =>
   PERSONNEL_FIELD_GROUPS.filter((g) => fields.value.some((f) => f.group === g.key)),
@@ -190,6 +200,8 @@ function displayText(f: PersonnelFieldDef): string {
   const s = String(raw)
   if (f.key === 'bizStatus') return personnelStatusLabel(s)
   if (f.key === 'insuranceMatchResult') return labelOf('insuranceMatch', s)
+  const named = f.options?.find((opt) => opt.value === s || opt.label === s)
+  if (named) return named.label
   return s
 }
 
@@ -385,4 +397,9 @@ watch(
   color: @text-secondary;
   line-height: 1.5;
 }
+.personnel-detail-descriptions :deep(.el-descriptions__label) {
+  width: 168px;
+  max-width: 40%;
+  vertical-align: middle;
+}
 </style>

+ 61 - 0
src/components/biz/PersonnelFieldLabel.vue

@@ -0,0 +1,61 @@
+<template>
+  <span class="personnel-field-label">
+    <span>{{ label }}</span>
+    <el-tooltip
+      v-if="tip"
+      :content="tip"
+      placement="top"
+      :show-after="200"
+      popper-class="personnel-field-tip-popper"
+    >
+      <span
+        class="personnel-field-label__help"
+        tabindex="0"
+        aria-label="字段说明"
+        @click.stop.prevent
+      >
+        <el-icon :size="13"><QuestionFilled /></el-icon>
+      </span>
+    </el-tooltip>
+  </span>
+</template>
+
+<script setup lang="ts">
+import { QuestionFilled } from '@element-plus/icons-vue'
+
+defineProps<{
+  label: string
+  tip?: string
+}>()
+</script>
+
+<style scoped>
+.personnel-field-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  max-width: 100%;
+  line-height: 1.35;
+}
+.personnel-field-label__help {
+  display: inline-flex;
+  align-items: center;
+  color: var(--el-text-color-secondary, #909399);
+  cursor: help;
+  flex-shrink: 0;
+}
+.personnel-field-label__help:hover,
+.personnel-field-label__help:focus-visible {
+  color: var(--el-color-primary, #409eff);
+  outline: none;
+}
+</style>
+
+<style>
+/* 挂到 body,需非 scoped */
+.personnel-field-tip-popper {
+  max-width: 320px;
+  line-height: 1.5;
+  font-size: 12px;
+}
+</style>

+ 68 - 12
src/components/biz/PersonnelFormFields.vue

@@ -1,14 +1,17 @@
 <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-divider content-position="left">{{ groupTitle(g) }}</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-form-item :prop="formProp(f.key)" :required="isRequired(f.key)">
+            <template #label>
+              <PersonnelFieldLabel :label="f.label" :tip="f.tip" />
+            </template>
             <!-- 街镇 -->
             <el-select
               v-if="f.control === 'town'"
@@ -42,7 +45,7 @@
               value-format="YYYY-MM"
               :disabled="!canEdit(f)"
               style="width: 100%"
-              placeholder="选填"
+              :placeholder="inputPlaceholder(f)"
               @update:model-value="setField(f.key, $event || '')"
             />
             <el-date-picker
@@ -52,7 +55,7 @@
               value-format="YYYY-MM-DD"
               :disabled="!canEdit(f)"
               style="width: 100%"
-              placeholder="选填"
+              :placeholder="inputPlaceholder(f)"
               @update:model-value="setField(f.key, $event || '')"
             />
             <el-input-number
@@ -62,7 +65,7 @@
               :precision="2"
               :controls="false"
               style="width: 100%"
-              placeholder="选填"
+              :placeholder="inputPlaceholder(f)"
               @update:model-value="setField(f.key, $event)"
             />
             <el-select
@@ -93,14 +96,14 @@
               type="textarea"
               :rows="2"
               :disabled="!canEdit(f)"
-              placeholder="选填"
+              :placeholder="inputPlaceholder(f)"
               @update:model-value="setField(f.key, $event)"
             />
             <el-input
               v-else
               :model-value="displayReadonly(f)"
               :disabled="!canEdit(f)"
-              :placeholder="canEdit(f) ? '选填' : '—'"
+              :placeholder="inputPlaceholder(f)"
               @update:model-value="setField(f.key, $event)"
             />
           </el-form-item>
@@ -119,6 +122,7 @@ import {
   type PersonnelFieldGroup,
   type PersonnelFieldKey,
 } from '@/constants/personnelFields'
+import PersonnelFieldLabel from '@/components/biz/PersonnelFieldLabel.vue'
 import type { Person } from '@/types/biz'
 import { useUserStore } from '@/stores/user'
 import { labelOf, personnelStatusLabel } from '@/utils/statusLabels'
@@ -159,7 +163,9 @@ const emit = defineEmits<{
   townChange: [string]
 }>()
 
-const fields = computed(() => visiblePersonnelFields())
+const fields = computed(() =>
+  visiblePersonnelFields({ role: userStore.currentRole, mode: props.mode }),
+)
 const groups = computed(() =>
   PERSONNEL_FIELD_GROUPS.filter((g) => fields.value.some((f) => f.group === g.key)),
 )
@@ -168,20 +174,64 @@ function fieldsOf(group: PersonnelFieldGroup) {
   return fields.value.filter((f) => f.group === group)
 }
 
+function groupTitle(g: { key: PersonnelFieldGroup; title: string }) {
+  if (userStore.currentRole === 'village' && props.mode === 'create' && g.key === 'status') {
+    return '退养资格'
+  }
+  if (userStore.currentRole === 'village' && props.mode === 'create' && g.key === 'region') {
+    return '区划(已按当前村带出)'
+  }
+  return g.title
+}
+
 function canEdit(f: PersonnelFieldDef) {
   if (f.editable === false) return false
-  if (f.key === 'retireQualifyType') return false
+  if (f.key === 'retireQualifyType') {
+    return props.mode === 'create' || userStore.currentRole === 'village'
+  }
+  const role = userStore.currentRole
+  if (role === 'leader' || role === 'agri' || role === 'hr') return false
+  if (role === 'town' && (f.group === 'bank' || f.group === 'pay' || f.group === 'death')) return false
   // 可研 5.1.2.4:编辑时继承人仅镇社区事务受理中心(及管理员)可改;新增录入可填
   if (HEIR_KEYS.has(f.key) && props.mode === 'edit') {
-    const role = userStore.currentRole
     return role === 'social' || role === 'admin'
   }
   return true
 }
 
 function selectPlaceholder(f: PersonnelFieldDef) {
-  if (f.key === 'retireQualifyType') return '由镇经发审批认定,录入不可选'
-  return canEdit(f) ? '选填' : '—'
+  if (f.key === 'retireQualifyType') return '村级默认无土地有资格,可改'
+  if (!canEdit(f)) {
+    if (
+      f.key === 'baseAmount' ||
+      f.key === 'oneTimeAmount' ||
+      f.key === 'landSubsidyIncrease' ||
+      f.key === 'funeralDiffAmount' ||
+      f.key === 'nextPayStartMonth' ||
+      f.key === 'standard'
+    ) {
+      return '系统计算/流程写入'
+    }
+    return '—'
+  }
+  return '选填'
+}
+
+function inputPlaceholder(f: PersonnelFieldDef) {
+  if (!canEdit(f)) {
+    if (
+      f.key === 'baseAmount' ||
+      f.key === 'oneTimeAmount' ||
+      f.key === 'landSubsidyIncrease' ||
+      f.key === 'funeralDiffAmount' ||
+      f.key === 'nextPayStartMonth' ||
+      f.key === 'standard'
+    ) {
+      return '系统计算/流程写入'
+    }
+    return '—'
+  }
+  return '选填'
 }
 
 function isRequired(key: PersonnelFieldKey) {
@@ -255,4 +305,10 @@ function onTownUpdate(v: string) {
   white-space: normal;
   word-break: break-word;
 }
+.personnel-form-fields :deep(.el-form-item__label) {
+  align-items: center;
+  line-height: 1.35;
+  height: auto;
+  min-height: 32px;
+}
 </style>

+ 2 - 0
src/components/common/ChartBox.vue

@@ -7,6 +7,7 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import * as echarts from 'echarts/core'
 import { BarChart, LineChart, PieChart } from 'echarts/charts'
 import {
+  DataZoomComponent,
   GridComponent,
   LegendComponent,
   TooltipComponent,
@@ -18,6 +19,7 @@ echarts.use([
   BarChart,
   LineChart,
   PieChart,
+  DataZoomComponent,
   GridComponent,
   LegendComponent,
   TooltipComponent,

+ 59 - 0
src/components/common/PersonNameCell.vue

@@ -0,0 +1,59 @@
+<template>
+  <span class="person-name-cell" :class="{ 'is-death-warn': warn }">
+    <span class="person-name-cell__name">{{ displayName }}</span>
+    <el-tag v-if="warn" type="danger" size="small" effect="plain">死亡预警</el-tag>
+    <slot />
+  </span>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { isDeathWarningPerson } from '@/utils/deathWarning'
+
+const props = defineProps<{
+  name?: string
+  person?: {
+    name?: string
+    deathWarning?: boolean
+    deathStatus?: string
+    deathDate?: string
+    bizStatus?: string
+    status?: string
+  }
+  deathWarning?: boolean
+  deathStatus?: string
+  deathDate?: string
+  bizStatus?: string
+  status?: string
+}>()
+
+const displayName = computed(
+  () => String(props.name || props.person?.name || '').trim() || '—',
+)
+const warn = computed(() =>
+  isDeathWarningPerson({
+    deathWarning: props.deathWarning ?? props.person?.deathWarning,
+    deathStatus: props.deathStatus ?? props.person?.deathStatus,
+    deathDate: props.deathDate ?? props.person?.deathDate,
+    bizStatus: props.bizStatus ?? props.person?.bizStatus,
+    status: props.status ?? props.person?.status,
+  }),
+)
+</script>
+
+<style scoped lang="less">
+.person-name-cell {
+  display: inline-flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.person-name-cell__name {
+  font-weight: 500;
+}
+
+.is-death-warn .person-name-cell__name {
+  color: var(--el-color-danger);
+}
+</style>

+ 204 - 0
src/components/common/PieMixPanel.vue

@@ -0,0 +1,204 @@
+<template>
+  <PagePanel :title="title" class="pie-mix-panel">
+    <div v-if="items.length" class="pie-mix">
+      <div class="pie-mix__chart">
+        <ChartBox :option="chartOption" height="100%" />
+      </div>
+      <ul class="pie-mix__list">
+        <li v-for="(item, idx) in items" :key="item.name">
+          <i class="pie-mix__dot" :style="{ background: PIE_COLORS[idx % PIE_COLORS.length] }" />
+          <span class="pie-mix__name">{{ item.name }}</span>
+          <strong>{{ formatCount(item.value) }}</strong>
+          <em>{{ percentOf(item.value) }}</em>
+        </li>
+        <li class="pie-mix__total">
+          <span class="pie-mix__name">合计</span>
+          <strong>{{ formatCount(total) }}</strong>
+          <em>{{ unit }}</em>
+        </li>
+      </ul>
+    </div>
+    <div v-else class="pie-mix__empty">暂无数据</div>
+  </PagePanel>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { EChartsCoreOption } from 'echarts/core'
+import PagePanel from '@/components/common/PagePanel.vue'
+import ChartBox from '@/components/common/ChartBox.vue'
+import type { ChartPieItem } from '@/types/biz'
+
+const PIE_COLORS = ['#2B73F8', '#27C98E', '#FA8C16', '#722ED1', '#1890FF']
+
+const props = withDefaults(
+  defineProps<{
+    title: string
+    items: ChartPieItem[]
+    unit?: string
+  }>(),
+  { unit: '人' },
+)
+
+const total = computed(() => props.items.reduce((sum, item) => sum + Number(item.value || 0), 0))
+
+function formatCount(n: number) {
+  return Number(n || 0).toLocaleString('zh-CN')
+}
+
+function percentOf(n: number) {
+  if (!total.value) return '—'
+  return `${((Number(n || 0) / total.value) * 100).toFixed(1)}%`
+}
+
+const chartOption = computed<EChartsCoreOption>(() => ({
+  color: PIE_COLORS,
+  tooltip: {
+    trigger: 'item',
+    formatter: '{b}: {c} ({d}%)',
+    backgroundColor: 'rgba(255,255,255,0.96)',
+    borderColor: '#d6e4ff',
+    textStyle: { color: '#333', fontSize: 12 },
+  },
+  legend: { show: false },
+  series: [
+    {
+      name: props.title,
+      type: 'pie',
+      radius: ['48%', '72%'],
+      center: ['50%', '50%'],
+      data: props.items,
+      label: { show: false },
+      labelLine: { show: false },
+      itemStyle: { borderColor: '#fff', borderWidth: 2 },
+      emphasis: {
+        scaleSize: 4,
+        itemStyle: { shadowBlur: 8, shadowColor: 'rgba(43,115,248,0.28)' },
+      },
+    },
+  ],
+}))
+</script>
+
+<style scoped lang="less">
+.pie-mix-panel {
+  min-height: 0;
+  overflow: visible;
+}
+
+.pie-mix {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 4px 12px;
+  min-height: 200px;
+  overflow: visible;
+}
+
+.pie-mix__chart {
+  flex: 1 1 46%;
+  min-width: 140px;
+  height: 188px;
+  position: relative;
+
+  :deep(.chart-box) {
+    position: absolute;
+    inset: 0;
+    height: 100% !important;
+  }
+}
+
+.pie-mix__list {
+  flex: 1 1 54%;
+  min-width: 168px;
+  margin: 0;
+  padding: 0 2px 0 0;
+  list-style: none;
+  overflow: visible;
+}
+
+.pie-mix__list li {
+  display: grid;
+  grid-template-columns: 8px minmax(0, 1fr) auto auto;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 0;
+  border-bottom: 1px dashed #efe8dc;
+  font-size: 12px;
+  line-height: 1.4;
+}
+
+.pie-mix__list li:last-child {
+  border-bottom: 0;
+}
+
+.pie-mix__dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+}
+
+.pie-mix__name {
+  min-width: 0;
+  color: @text-regular;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.pie-mix__list strong {
+  font-size: 13px;
+  font-weight: 700;
+  color: @text-primary;
+  font-variant-numeric: tabular-nums;
+}
+
+.pie-mix__list em {
+  min-width: 44px;
+  font-style: normal;
+  color: @text-secondary;
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+}
+
+.pie-mix__list li.pie-mix__total {
+  grid-template-columns: minmax(0, 1fr) auto auto;
+  margin-top: 2px;
+  padding-top: 8px;
+  border-bottom: 0;
+  border-top: 1px solid @border-light;
+
+  .pie-mix__name {
+    overflow: visible;
+    font-weight: 700;
+    color: @text-primary;
+  }
+
+  strong {
+    font-weight: 700;
+    color: @text-primary;
+  }
+}
+
+.pie-mix__empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 188px;
+  font-size: 13px;
+  color: @text-secondary;
+}
+
+@media (max-width: 520px) {
+  .pie-mix {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .pie-mix__chart {
+    width: 100%;
+    height: 200px;
+  }
+}
+</style>

+ 2 - 2
src/components/common/SensitiveRevealSwitch.vue

@@ -16,8 +16,8 @@ withDefaults(
     inactiveText?: string
   }>(),
   {
-    activeText: '显示',
-    inactiveText: '脱敏',
+    activeText: '已显示证件卡号',
+    inactiveText: '显示身份证和银行卡号',
   },
 )
 

+ 33 - 2
src/components/common/StatCards.vue

@@ -1,7 +1,12 @@
 <template>
-  <div class="stat-cards">
+  <div class="stat-cards" :class="{ 'stat-cards--five': items.length >= 5 }">
     <div v-for="item in items" :key="item.label" class="stat-cards__item">
-      <div class="stat-cards__label">{{ item.label }}</div>
+      <div class="stat-cards__label">
+        <span>{{ item.label }}</span>
+        <el-tooltip v-if="item.tip" :content="item.tip" placement="top" :show-after="200">
+          <span class="stat-cards__tip" tabindex="0">?</span>
+        </el-tooltip>
+      </div>
       <div class="stat-cards__value">
         {{ item.value }}
         <span v-if="item.unit" class="stat-cards__unit">{{ item.unit }}</span>
@@ -15,6 +20,7 @@ export interface StatCardItem {
   label: string
   value: string | number
   unit?: string
+  tip?: string
 }
 
 defineProps<{
@@ -41,12 +47,37 @@ defineProps<{
     padding: 14px 16px;
   }
 
+  &--five {
+    grid-template-columns: repeat(5, 1fr);
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(3, 1fr);
+    }
+  }
+
   &__label {
+    display: flex;
+    align-items: center;
+    gap: 6px;
     font-size: 13px;
     color: rgba(255, 255, 255, 0.88);
     margin-bottom: 8px;
   }
 
+  &__tip {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 14px;
+    height: 14px;
+    border-radius: 50%;
+    border: 1px solid rgba(255, 255, 255, 0.55);
+    font-size: 10px;
+    line-height: 1;
+    cursor: help;
+    color: rgba(255, 255, 255, 0.9);
+  }
+
   &__value {
     font-size: 22px;
     font-weight: 700;

+ 236 - 0
src/components/common/WorkbenchTipsPanel.vue

@@ -0,0 +1,236 @@
+<template>
+  <section v-if="tips.length" class="workbench-tips" :class="{ 'is-open': open }">
+    <button type="button" class="workbench-tips__toggle" @click="open = !open">
+      <span class="workbench-tips__mark" aria-hidden="true">示</span>
+      <span class="workbench-tips__head">
+        <strong>{{ title }}</strong>
+        <em>{{ tips.length }} 步</em>
+      </span>
+      <span v-if="!open" class="workbench-tips__preview">{{ previewText }}</span>
+      <span class="workbench-tips__action">
+        {{ open ? '收起' : '展开查看' }}
+        <el-icon class="workbench-tips__chevron" :class="{ 'is-open': open }">
+          <ArrowDown />
+        </el-icon>
+      </span>
+    </button>
+    <div v-show="open" class="workbench-tips__body">
+      <div class="workbench-tips__steps">
+        <div v-for="(tip, idx) in tips" :key="idx" class="workbench-tips__item">
+          <div class="workbench-tips__rail">
+            <span class="workbench-tips__index">{{ idx + 1 }}</span>
+            <span v-if="idx < tips.length - 1" class="workbench-tips__line" />
+          </div>
+          <div class="workbench-tips__card">
+            <div class="workbench-tips__title">{{ tip.title }}</div>
+            <p class="workbench-tips__desc">{{ tip.desc }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { ArrowDown } from '@element-plus/icons-vue'
+
+const props = defineProps<{
+  title: string
+  tips: Array<{ title: string; desc: string }>
+  /** 切换角色等场景传入,用于回到默认收起 */
+  resetKey?: string
+}>()
+
+const open = ref(false)
+
+const previewText = computed(() => {
+  const titles = props.tips.map((t) => t.title).filter(Boolean)
+  if (!titles.length) return '点击展开办理指引'
+  return titles.slice(0, 4).join('  →  ') + (titles.length > 4 ? '  →  …' : '')
+})
+
+watch(
+  () => props.resetKey,
+  () => {
+    open.value = false
+  },
+)
+</script>
+
+<style scoped lang="less">
+.workbench-tips {
+  background: @block-bg;
+  border: 1px solid @border-light;
+  overflow: hidden;
+}
+
+.workbench-tips__toggle {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+  margin: 0;
+  padding: 10px 14px;
+  border: 0;
+  background: linear-gradient(90deg, #eef4ff 0%, #f7f9fc 55%, #fff 100%);
+  cursor: pointer;
+  text-align: left;
+  color: inherit;
+
+  &:hover {
+    background: linear-gradient(90deg, #e4eeff 0%, #f4f7fb 55%, #fff 100%);
+  }
+}
+
+.workbench-tips__mark {
+  flex-shrink: 0;
+  width: 28px;
+  height: 28px;
+  border-radius: 6px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 700;
+  color: #fff;
+  background: @primary-color;
+}
+
+.workbench-tips__head {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+
+  strong {
+    font-size: 14px;
+    font-weight: 700;
+    color: @text-primary;
+  }
+
+  em {
+    font-style: normal;
+    font-size: 12px;
+    color: @primary-color;
+    background: rgba(43, 115, 248, 0.08);
+    border: 1px solid #d6e4ff;
+    border-radius: 10px;
+    padding: 0 8px;
+    line-height: 20px;
+  }
+}
+
+.workbench-tips__preview {
+  flex: 1;
+  min-width: 0;
+  font-size: 12px;
+  color: @text-secondary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.workbench-tips__action {
+  flex-shrink: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin-left: auto;
+  font-size: 12px;
+  color: @primary-color;
+}
+
+.workbench-tips__chevron {
+  transition: transform 0.18s ease;
+
+  &.is-open {
+    transform: rotate(180deg);
+  }
+}
+
+.workbench-tips__body {
+  padding: 4px 14px 12px;
+  border-top: 1px solid @border-light;
+  background: #fff;
+}
+
+.workbench-tips__steps {
+  display: flex;
+  flex-direction: column;
+}
+
+.workbench-tips__item {
+  display: grid;
+  grid-template-columns: 28px minmax(0, 1fr);
+  gap: 12px;
+}
+
+.workbench-tips__rail {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-top: 10px;
+}
+
+.workbench-tips__index {
+  position: relative;
+  z-index: 1;
+  flex-shrink: 0;
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  font-weight: 700;
+  color: #fff;
+  background: linear-gradient(145deg, @brand-gold-deep 0%, @primary-color 100%);
+  box-shadow: 0 1px 3px rgba(43, 115, 248, 0.28);
+}
+
+.workbench-tips__line {
+  flex: 1;
+  width: 2px;
+  margin: 4px 0 2px;
+  min-height: 12px;
+  background: linear-gradient(180deg, rgba(43, 115, 248, 0.55), rgba(43, 115, 248, 0.12));
+  border-radius: 1px;
+}
+
+.workbench-tips__card {
+  min-width: 0;
+  padding: 10px 12px;
+  margin: 8px 0 2px;
+  background: linear-gradient(135deg, #f5f9ff 0%, #eef4ff 100%);
+  border: 1px solid #d6e4ff;
+  border-left: 3px solid @primary-color;
+  border-radius: 0 4px 4px 0;
+}
+
+.workbench-tips__item:last-child .workbench-tips__card {
+  margin-bottom: 0;
+}
+
+.workbench-tips__title {
+  font-size: 13px;
+  font-weight: 700;
+  color: @text-primary;
+  line-height: 1.3;
+}
+
+.workbench-tips__desc {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.6;
+}
+
+@media (max-width: 768px) {
+  .workbench-tips__preview {
+    display: none;
+  }
+}
+</style>

+ 1 - 1
src/constants/menus.ts

@@ -10,7 +10,7 @@ export interface MenuItem {
 }
 
 export const MENU_LIST: MenuItem[] = [
-  { path: '/dashboard', title: '工作台首页', icon: 'Odometer' },
+  { path: '/dashboard', title: '数据展板', icon: 'Odometer' },
   { path: '/base', title: '人员新增录入', icon: 'User' },
   { path: '/insurance', title: '居保状态匹配', icon: 'Connection' },
   { path: '/approvals', title: '审批管理', icon: 'Checked' },

+ 344 - 40
src/constants/personnelFields.ts

@@ -11,6 +11,7 @@ export type PersonnelFieldKey =
   | 'socialInsuranceCode'
   | 'phoneNumber'
   | 'address'
+  | 'householdAddress'
   | 'householdType'
   | 'districtId'
   | 'town'
@@ -40,14 +41,37 @@ export type PersonnelFieldKey =
   | 'payThisMonth'
   | 'source'
   | 'inputVillageId'
+  | 'deathStatus'
   | 'deathDate'
   | 'heirName'
   | 'heirRelation'
   | 'heirIdNumber'
   | 'heirBankCard'
   | 'heirPhone'
+  | 'personType'
+  | 'urbanMedicareConfirm'
+  | 'householdDistrict'
+  | 'householdTown'
+  | 'householdVillage'
+  | 'contactName'
+  | 'contactPhone'
+  | 'insuranceJoinDate'
+  | 'personMark'
+  | 'changeReason'
+  | 'benefitApplyDate'
+  | 'sentenceStart'
+  | 'sentenceEnd'
+  | 'cancelApplyDate'
+  | 'cancelReason'
+  | 'cancelDeathMonth'
+  | 'hasHeir'
+  | 'heirIdType'
+  | 'heirBankName'
+  | 'heirHouseholdAddress'
+  | 'heirAddress'
   | 'funeralDiffAmount'
   | 'landSubsidyIncrease'
+  | 'localOrRemote'
   | 'retireQualifyType'
   | 'remark'
   | 'createTime'
@@ -70,12 +94,16 @@ export interface PersonnelFieldDef {
   editable?: boolean
   /** 人员详情是否隐藏(避免与展示字段重复) */
   detailHidden?: boolean
+  /** 新增表单不展示(系统计算/业务流程写入) */
+  createHidden?: 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'
+  /** 详情/录入标签旁问号悬停说明 */
+  tip?: string
 }
 
 export const PERSONNEL_FIELD_GROUPS: Array<{ key: PersonnelFieldGroup; title: string }> = [
@@ -90,15 +118,20 @@ export const PERSONNEL_FIELD_GROUPS: Array<{ key: PersonnelFieldGroup; title: st
 
 /** 表单/详情不展示的内部字段 */
 export const HIDDEN_PERSONNEL_FIELD_KEYS = new Set<PersonnelFieldKey>([
+  'id', // 业务内部编号,客户侧无需展示
   'districtId',
   'townId',
   'villageId',
   'inputVillageId',
   'bizStatus', // 与 status 中文展示重复
+  'villageCode',
+  'entrustPlanCode',
+  'entrustPlanName',
+  'bankBranchCode',
 ])
 
 export const PERSONNEL_FIELDS: PersonnelFieldDef[] = [
-  { key: 'id', label: '人员ID', group: 'basic', editable: false },
+  { key: 'id', label: '人员ID', group: 'basic', editable: false, tip: '系统人员业务编号,列表与审批关联用,录入时不可改。' },
   { key: 'name', label: '姓名', group: 'basic', editable: true, control: 'text' },
   { key: 'idCard', label: '公民身份证号码', group: 'basic', editable: true, control: 'text', mask: 'idCard' },
   {
@@ -107,75 +140,287 @@ export const PERSONNEL_FIELDS: PersonnelFieldDef[] = [
     group: 'basic',
     editable: true,
     control: 'text',
+    tip: '个人社会保险登记号,用于居保等外部匹配;可空。',
   },
   { key: 'phoneNumber', label: '手机号', group: 'basic', editable: true, control: 'text' },
-  { key: 'address', label: '家庭住址/联系地址', group: 'basic', editable: true, control: 'textarea', span: 2 },
+  {
+    key: 'address',
+    label: '联系地址',
+    group: 'basic',
+    editable: true,
+    control: 'textarea',
+    span: 2,
+    tip: '日常联系、送达用地址。',
+  },
+  {
+    key: 'householdAddress',
+    label: '户籍地址',
+    group: 'basic',
+    editable: true,
+    control: 'textarea',
+    span: 2,
+    tip: '户口所在地址,与联系地址分开登记。',
+  },
   {
     key: 'householdType',
     label: '户籍性质',
     group: 'basic',
     editable: true,
     control: 'select',
+    tip: '农业 / 非农。纸质新增名单填农或非;无土地须本区农业户口。',
     options: [
       { label: '农业', value: '农业' },
       { label: '非农', value: '非农' },
     ],
   },
+  { key: 'personType', label: '人员类型', group: 'basic', editable: true, createHidden: true, control: 'text', tip: '档案分类,如老年农民。村级单人新增不填。' },
+  { key: 'householdDistrict', label: '户籍区县', group: 'basic', editable: true, createHidden: true, control: 'text', tip: '户籍所在区县,与管理区划分开。' },
+  { key: 'householdTown', label: '户籍街镇', group: 'basic', editable: true, createHidden: true, control: 'text', tip: '户籍所在街镇,与当前管理街镇分开。' },
+  { key: 'householdVillage', label: '户籍村居委', group: 'basic', editable: true, createHidden: true, control: 'text', tip: '户籍村居委,与当前管理村居分开。' },
+  { key: 'contactName', label: '联系人', group: 'basic', editable: true, createHidden: true, control: 'text' },
+  { key: 'contactPhone', label: '联系电话', group: 'basic', editable: true, createHidden: true, control: 'text', tip: '联系人电话,与本人手机号分开。' },
+  { key: 'insuranceJoinDate', label: '参保日期', group: 'basic', editable: true, createHidden: true, control: 'date' },
+  { key: 'benefitApplyDate', label: '待遇申领日期', group: 'basic', editable: true, createHidden: true, control: 'date' },
+  { key: 'urbanMedicareConfirm', label: '转至城镇医保确认状态', group: 'basic', editable: true, createHidden: true, control: 'text' },
+  { key: 'personMark', label: '人员标示', group: 'basic', editable: true, createHidden: true, control: 'text' },
   { 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: 'villageCode',
+    label: '村委代码',
+    group: 'region',
+    editable: true,
+    control: 'text',
+    tip: '村委/村居业务代码(可选),便于与外部台账对照。',
+  },
+  {
+    key: 'entrustPlanCode',
+    label: '委托方案代码',
+    group: 'region',
+    editable: true,
+    control: 'text',
+    tip: '历史委托发放方案编码(如方案一/二对应代码),统计与导入对照用。',
+  },
+  {
+    key: 'entrustPlanName',
+    label: '委托方案名称',
+    group: 'region',
+    editable: true,
+    control: 'text',
+    tip: '委托方案中文名,与代码对应;可空。',
+  },
   { key: 'inputVillageId', label: '录入村居ID', group: 'region', editable: false },
-  { key: 'payeeName', label: '收款人名称', group: 'bank', editable: true, control: 'text' },
+  {
+    key: 'payeeName',
+    label: '收款人名称',
+    group: 'bank',
+    editable: true,
+    control: 'text',
+    tip: '银行卡户名;默认可与本人姓名相同,继承人代领时填继承人。',
+  },
   { key: 'bankCard', label: '银行卡号', group: 'bank', editable: true, control: 'text', mask: 'bankCard' },
-  { key: 'bankName', label: '开户行', group: 'bank', editable: true, control: 'text' },
+  {
+    key: 'bankName',
+    label: '开户行',
+    group: 'bank',
+    editable: true,
+    control: 'text',
+    tip: '开户银行全称或网点名称,用于出盘对照。',
+  },
   {
     key: 'bankType',
     label: '行别',
     group: 'bank',
     editable: true,
     control: 'select',
+    tip: '银行类别全称,如中国农业银行、上海农商银行;出盘仍按农行、农商等行别拆分清单。',
     options: [
-      { label: '农行', value: '农行' },
-      { label: '农商', value: '农商' },
-      { label: '工行', value: '工行' },
-      { label: '建行', value: '建行' },
+      { 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: 'bankBranchCode',
+    label: '收款银行行号',
+    group: 'bank',
+    editable: true,
+    control: 'text',
+    tip: '收款银行联行号/网点行号,财政出盘或银行代发时核对用;可空。',
+  },
+  {
+    key: 'startMonth',
+    label: '享受/代发起始年月',
+    group: 'pay',
+    editable: true,
+    control: 'month',
+    tip: '待遇起算年月(档案口径)。历史导入人员常为首次享受月。',
+  },
+  {
+    key: 'enjoyEndMonth',
+    label: '代发截止年月',
+    group: 'pay',
+    editable: true,
+    control: 'month',
+    tip: '代发结束月;有值表示该月之后不再按月续发(停发/退出等)。',
+  },
+  {
+    key: 'supplementStartMonth',
+    label: '补发开始年月',
+    group: 'pay',
+    editable: true,
+    control: 'month',
+    tip: '补发从哪个月起算(恢复发放、回盘失败补发等)。空表示当前无独立补发起点。',
+  },
+  {
+    key: 'nextPayStartMonth',
+    label: '下次发放起始年月',
+    group: 'pay',
+    editable: false,
+    createHidden: true,
+    control: 'month',
+    tip: '资格通过后由系统写入:通过日在当月 15 号及之前可进当月月批,16 号及之后进下月。新增时不可填。',
+  },
+  {
+    key: 'standard',
+    label: '当前月度补助标准(元)',
+    group: 'pay',
+    editable: false,
+    control: 'number',
+    tip: '按所属街镇金额标准表当前有效土地月补带出(只读)。出盘计发也以街镇标准为准,不以人员冗余为准。',
+  },
+  {
+    key: 'baseAmount',
+    label: '代发基数',
+    group: 'pay',
+    editable: false,
+    createHidden: true,
+    control: 'number',
+    tip: '历史台账/系统维护用基数,非村级新增填报项;日常月发不以该字段为主算式。',
+  },
+  {
+    key: 'oneTimeAmount',
+    label: '一次性代发金额',
+    group: 'pay',
+    editable: false,
+    createHidden: true,
+    control: 'number',
+    tip: '由特殊业务/发放明细流程写入后并入当月应发,不在人员新增时手填。',
+  },
+  {
+    key: 'landSubsidyIncrease',
+    label: '土地退养增资额',
+    group: 'pay',
+    editable: false,
+    createHidden: true,
+    control: 'number',
+    tip: '调标/增资业务流程落库;常规月发跟街镇标准,差额走调标专批。新增不可填。',
+  },
+  {
+    key: 'funeralDiffAmount',
+    label: '丧葬费补差额',
+    group: 'pay',
+    editable: false,
+    createHidden: true,
+    control: 'number',
+    tip: '死亡/丧葬结算后由系统写入待发丧葬额,不在人员新增时手填。',
+  },
+  {
+    key: 'insuranceStatus',
+    label: '居保养老状态',
+    group: 'status',
+    editable: false,
+    createHidden: true,
+    tip: '居保匹配返回的养老状态原文/展示文案。',
+  },
+  {
+    key: 'insuranceMatchResult',
+    label: '居保匹配结果',
+    group: 'status',
+    editable: false,
+    createHidden: true,
+    detailHidden: true,
+    tip: '匹配结果码:待匹配/成功/失败/人工通过等,决定能否进入正常月发。',
+  },
+  {
+    key: 'bankCardConfirmStatus',
+    label: '银行卡确认状态',
+    group: 'status',
+    editable: false,
+    createHidden: true,
+    tip: '银行卡核验/确认进度(如待确认、已确认)。',
+  },
+  {
+    key: 'status',
+    label: '人员业务状态',
+    group: 'status',
+    editable: false,
+    createHidden: true,
+    tip: '草稿、待资格审批、新增、正常发放、暂停、停止、死亡丧葬等业务态,驱动能否进月批。',
+  },
   { key: 'bizStatus', label: '人员业务状态', group: 'status', editable: false, detailHidden: true },
-  { key: 'payThisMonth', label: '是否本月发放', group: 'status', editable: false, control: 'switch' },
-  { key: 'retireQualifyType', label: '退养资格类型', group: 'status', editable: false, 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: 'payThisMonth',
+    label: '是否本月发放',
+    group: 'status',
+    editable: false,
+    createHidden: true,
+    control: 'switch',
+    tip: '是否计入本月发放意向。暂停/停止等会为否;已纳入名单的暂停人员当月仍可能按规则计发。',
+  },
+  {
+    key: 'localOrRemote',
+    label: '同城/异地',
+    group: 'bank',
+    editable: true,
+    control: 'select',
+    tip: '收款账户同城或异地。',
+    options: [
+      { label: '同城', value: '同城' },
+      { label: '异地', value: '异地' },
+    ],
+  },
+  {
+    key: 'retireQualifyType',
+    label: '退养资格类型',
+    group: 'status',
+    editable: true,
+    control: 'select',
+    tip: '村级新增默认「无土地有资格」,导入/接口默认「有土地退养」,可改。无土地须满足:城乡居保养老保险、退休(社保判断)、本区农业户口。有土地须满足:城乡居民养老保险、退休、有本区承包地。镇经发只核实通过/驳回。',
+    options: [
+      { label: '有土地退养', value: '有土地退养' },
+      { label: '无土地有资格', value: '无土地有资格' },
+    ],
+  },
+  {
+    key: 'deathStatus',
+    label: '死亡状态',
+    group: 'death',
+    editable: false,
+    control: 'text',
+    tip: '大数据匹配或死亡登记结果:正常 / 已死亡。与业务态「死亡丧葬」并存,不替代业务状态。',
+  },
+  {
+    key: 'deathDate',
+    label: '死亡日期',
+    group: 'death',
+    editable: true,
+    control: 'date',
+    tip: '死亡日期,用于当月/跨月死亡结算(是否发土地月补、丧葬冲减)。',
+  },
+  { key: 'heirName', label: '继承人姓名', group: 'death', editable: true, control: 'text', tip: '丧葬/代领时的继承人姓名;编辑时仅镇社区事务受理中心可改。' },
   {
     key: 'heirRelation',
     label: '与逝者关系',
     group: 'death',
     editable: true,
     control: 'select',
+    tip: '继承人与逝者关系,如配偶、子女等。',
     options: [
       { label: '配偶', value: '配偶' },
       { label: '子女', value: '子女' },
@@ -185,16 +430,50 @@ export const PERSONNEL_FIELDS: PersonnelFieldDef[] = [
     ],
   },
   { key: 'heirIdNumber', label: '继承人身份证', group: 'death', editable: true, control: 'text', mask: 'idCard' },
-  { key: 'heirBankCard', label: '继承人银行卡', group: 'death', editable: true, control: 'text', mask: 'bankCard' },
+  { key: 'heirBankCard', label: '继承人银行卡', group: 'death', editable: true, control: 'text', mask: 'bankCard', tip: '继承人收款卡号;丧葬发放可打到此卡。' },
   { key: 'heirPhone', label: '继承人电话', group: 'death', editable: true, control: 'text' },
-  { key: 'source', label: '录入来源', group: 'other', editable: true, control: 'text' },
+  { key: 'hasHeir', label: '是否有继承人', group: 'death', editable: false, createHidden: true, control: 'text', tip: '死亡登记审批通过后写入:有继承人姓名为「是」,否则为「否」。' },
+  { key: 'heirIdType', label: '继承人证件类型', group: 'death', editable: true, createHidden: true, control: 'text', tip: '未填且有证件号码时,死亡登记按居民身份证写入。' },
+  { key: 'heirBankName', label: '继承人银行名称', group: 'death', editable: true, createHidden: true, control: 'text' },
+  { key: 'heirHouseholdAddress', label: '继承人户籍地址', group: 'death', editable: true, createHidden: true, control: 'text' },
+  { key: 'heirAddress', label: '继承人联系地址', group: 'death', editable: true, createHidden: true, control: 'text' },
+  { key: 'sentenceStart', label: '刑期开始时间', group: 'status', editable: false, createHidden: true, control: 'date', tip: '暂停发放且原因为服刑时,审批通过后写入。' },
+  { key: 'sentenceEnd', label: '刑期结束时间', group: 'status', editable: false, createHidden: true, control: 'date', tip: '与刑期开始成对写入;恢复发放后仍保留,作为停发依据。' },
+  { key: 'changeReason', label: '变更原因', group: 'status', editable: false, createHidden: true, control: 'textarea', tip: '暂停、停止、恢复或死亡登记审批通过后写入。' },
+  { key: 'cancelApplyDate', label: '注销申办日期', group: 'death', editable: false, createHidden: true, control: 'date', tip: '死亡登记提交日。审批通过后写入。' },
+  { key: 'cancelReason', label: '注销原因', group: 'death', editable: false, createHidden: true, control: 'text', tip: '死亡登记通过后写「死亡」。' },
+  { key: 'cancelDeathMonth', label: '注销(死亡)年月', group: 'death', editable: false, createHidden: true, control: 'text', tip: '由死亡日期取年月,格式 yyyyMM。' },
+  { key: 'source', label: '录入来源', group: 'other', editable: true, control: 'text', createHidden: true, tip: '如村级录入、Excel导入、历史台账等。' },
   { 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 },
+  { key: 'createTime', label: '创建时间', group: 'other', editable: false, createHidden: true },
+  { key: 'updateTime', label: '更新时间', group: 'other', editable: false, createHidden: true },
 ]
 
-export function visiblePersonnelFields(): PersonnelFieldDef[] {
-  return PERSONNEL_FIELDS.filter((f) => !HIDDEN_PERSONNEL_FIELD_KEYS.has(f.key))
+export function visiblePersonnelFields(opts?: {
+  role?: string
+  mode?: 'create' | 'edit' | 'detail'
+}): PersonnelFieldDef[] {
+  const role = opts?.role
+  const mode = opts?.mode
+  return PERSONNEL_FIELDS.filter((f) => {
+    if (HIDDEN_PERSONNEL_FIELD_KEYS.has(f.key)) return false
+    if (mode === 'create' && f.createHidden) return false
+    if (mode === 'detail' && f.detailHidden) return false
+    if (role === 'village' && f.group === 'bank') return false
+    if (role === 'village' && mode === 'create') {
+      return f.key === 'name' || f.key === 'idCard' || f.key === 'town' || f.key === 'village'
+    }
+    if (role === 'village') {
+      if (f.group === 'death') return mode === 'detail'
+    }
+    if (role === 'town' && f.group === 'death') return mode === 'detail'
+    if (role === 'agri' && f.group === 'bank') return false
+    if ((role === 'leader' || role === 'hr') && f.group === 'death') return mode === 'detail'
+    if ((role === 'leader' || role === 'agri' || role === 'hr') && mode === 'create') {
+      return false
+    }
+    return true
+  })
 }
 
 export function emptyPersonForm(partial?: Partial<Person>): Person {
@@ -208,6 +487,8 @@ export function emptyPersonForm(partial?: Partial<Person>): Person {
     bankCard: '',
     bankName: '',
     socialInsuranceCode: '',
+    localOrRemote: '同城',
+    retireQualifyType: '无土地有资格',
     standard: undefined,
     startMonth: '',
     status: '',
@@ -216,7 +497,8 @@ export function emptyPersonForm(partial?: Partial<Person>): Person {
     remark: '',
     phoneNumber: '',
     address: '',
-    householdType: '',
+    householdAddress: '',
+    householdType: '农业',
     districtId: '',
     townId: '',
     villageId: '',
@@ -236,12 +518,34 @@ export function emptyPersonForm(partial?: Partial<Person>): Person {
     bizStatus: '',
     payThisMonth: undefined,
     inputVillageId: '',
+    deathStatus: '',
     deathDate: '',
     heirName: '',
     heirRelation: '',
     heirIdNumber: '',
     heirBankCard: '',
     heirPhone: '',
+    personType: '',
+    urbanMedicareConfirm: '',
+    householdDistrict: '',
+    householdTown: '',
+    householdVillage: '',
+    contactName: '',
+    contactPhone: '',
+    insuranceJoinDate: '',
+    personMark: '',
+    changeReason: '',
+    benefitApplyDate: '',
+    sentenceStart: '',
+    sentenceEnd: '',
+    cancelApplyDate: '',
+    cancelReason: '',
+    cancelDeathMonth: '',
+    hasHeir: '',
+    heirIdType: '',
+    heirBankName: '',
+    heirHouseholdAddress: '',
+    heirAddress: '',
     funeralDiffAmount: undefined,
     landSubsidyIncrease: undefined,
     createTime: '',

+ 28 - 1
src/layouts/MainLayout.vue

@@ -57,7 +57,7 @@
             @change="onRoleChange"
           >
             <el-option
-              v-for="r in userStore.roles"
+              v-for="r in userStore.visibleRoles"
               :key="r.key"
               :label="r.label"
               :value="r.key"
@@ -118,6 +118,8 @@
 import { computed, onMounted, ref, watch, type Component } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import * as Icons from '@element-plus/icons-vue'
+import { ElMessageBox } from 'element-plus'
+import { fetchDistrictAdjustNotice } from '@/api/biz'
 import { getAppConfig } from '@/utils/appConfig'
 import { MENU_LIST } from '@/constants/menus'
 import { useUserStore } from '@/stores/user'
@@ -232,6 +234,7 @@ function onRoleChange(key: string) {
   if (!userStore.canAccess(route.path)) {
     router.push(userStore.allowedPaths[0] || '/dashboard')
   }
+  void maybeShowAdjustNotice()
 }
 
 function onTownChange(town: string | null) {
@@ -248,6 +251,29 @@ function onVillageChange(village: string) {
   userStore.setScopeVillage(village)
 }
 
+const TOWN_ADJUST_NOTICE_ROLES = new Set(['town', 'social', 'leader'])
+
+async function maybeShowAdjustNotice() {
+  const key = userStore.roleInfo?.key || ''
+  if (!TOWN_ADJUST_NOTICE_ROLES.has(key)) return
+  try {
+    const notice = await fetchDistrictAdjustNotice()
+    if (!notice?.adjusted) return
+    const year = String(notice.year || '')
+    const stamp = String(notice.adjustTime || '1')
+    const seenKey = `sj_tdtybz_adjust_notice_${year}`
+    if (localStorage.getItem(seenKey) === stamp) return
+    await ElMessageBox.alert(
+      notice.message || `区人社已完成${year}年调标,系统已自动生成调标补发批次,请知悉。`,
+      '调标提示',
+      { confirmButtonText: '知道了', type: 'warning' },
+    )
+    localStorage.setItem(seenKey, stamp)
+  } catch {
+    /* 通知失败不挡登录 */
+  }
+}
+
 watch(
   () => userStore.scopeTown,
   (town) => {
@@ -259,6 +285,7 @@ onMounted(async () => {
   await loadTownOptions()
   if (userStore.scopeTown) await loadVillageOptions(userStore.scopeTown)
   ensureDefaultScope()
+  void maybeShowAdjustNotice()
 })
 </script>
 

+ 1 - 1
src/router/routes.ts

@@ -9,7 +9,7 @@ export const businessRoutes: RouteRecordRaw[] = [
     path: 'dashboard',
     name: 'Dashboard',
     component: () => import('@/views/dashboard/DashboardView.vue'),
-    meta: { title: '工作台首页' },
+    meta: { title: '数据展板' },
   },
   {
     path: 'base',

+ 16 - 2
src/stores/user.ts

@@ -1,6 +1,6 @@
 import { defineStore } from 'pinia'
 import { computed, ref } from 'vue'
-import { getAppConfig, type RoleItem } from '@/utils/appConfig'
+import { getAppConfig, isAdminRoleVisible, type RoleItem } from '@/utils/appConfig'
 
 const ROLE_STORAGE_KEY = 'sj_tdtybz_current_role'
 const MONTH_STORAGE_KEY = 'sj_tdtybz_current_month'
@@ -26,7 +26,16 @@ function writeStored(key: string, value: string) {
 
 function readStoredRole(fallback: string) {
   const saved = readStored(ROLE_STORAGE_KEY)
-  if (saved && getAppConfig().roles.some((r) => r.key === saved)) return saved
+  const roles = getAppConfig().roles
+  if (saved && roles.some((r) => r.key === saved)) {
+    if (saved === 'admin' && !isAdminRoleVisible()) {
+      return roles.some((r) => r.key === 'social') ? 'social' : fallback
+    }
+    return saved
+  }
+  if (fallback === 'admin' && !isAdminRoleVisible()) {
+    return roles.some((r) => r.key === 'social') ? 'social' : fallback
+  }
   return fallback
 }
 
@@ -44,6 +53,9 @@ export const useUserStore = defineStore('user', () => {
   const scopeVillage = ref(readStored(VILLAGE_STORAGE_KEY, ''))
 
   const roles = computed(() => getAppConfig().roles)
+  const visibleRoles = computed(() =>
+    isAdminRoleVisible() ? roles.value : roles.value.filter((r) => r.key !== 'admin'),
+  )
 
   const baseRole = computed<RoleItem | undefined>(() =>
     roles.value.find((r) => r.key === currentRole.value),
@@ -130,6 +142,7 @@ export const useUserStore = defineStore('user', () => {
   }
 
   function switchRole(key: string) {
+    if (key === 'admin' && !isAdminRoleVisible()) return
     if (!roles.value.some((r) => r.key === key)) return
     currentRole.value = key
     writeStored(ROLE_STORAGE_KEY, key)
@@ -232,6 +245,7 @@ export const useUserStore = defineStore('user', () => {
     townScopeRequired,
     scopeKey,
     roles,
+    visibleRoles,
     roleInfo,
     allowedPaths,
     alertReadonly,

+ 63 - 5
src/types/biz.ts

@@ -10,8 +10,12 @@ export interface Person {
   village: string
   bankCard: string
   bankName: string
-  /** 社会保险登记码 */
+  /** 社会保险登记码 / 社保卡号 */
   socialInsuranceCode?: string
+  /** 同城 / 异地 */
+  localOrRemote?: '同城' | '异地' | ''
+  /** 死亡预警(仅提示,不改业务状态) */
+  deathWarning?: boolean
   /** 街镇当前有效月补标准;该镇无标准时为 undefined,勿展示陈旧值 */
   standard?: number
   /** 享受/代发起始年月 YYYY-MM */
@@ -19,17 +23,22 @@ export interface Person {
   status: string
   source: string
   insuranceStatus: string
+  /** 居保匹配失败原因,如非农保退休 */
+  insuranceReason?: string
   remark?: string
   /**
-   * 退养资格类型(镇经发中心初审核验;与 status/bizStatus 分离展示)
-   * - 有土地退养:确有承包地/退养土地
-   * - 无土地有资格:无土地但按规定仍具备退养补助资格
-   * - 无资格:不具备退养补助资格,批次须驳回由村处理
+   * 退养资格类型(村级新增勾选;镇经发只通过/驳回;与 status/bizStatus 分离)
+   * - 有土地退养:城乡居民养老保险 + 退休 + 有本区承包地
+   * - 无土地有资格:城乡居保养老保险 + 退休(社保判断)+ 本区农业户口
+   * - 无资格:不具备退养补助资格,驳回由村处理
    */
   retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
   /** —— 以下对齐人员表 / PersonnelVO 全字段(展示用;后续按需隐藏) —— */
   phoneNumber?: string
+  /** 联系地址 */
   address?: string
+  /** 户籍地址 */
+  householdAddress?: string
   /** 户籍性质:农业/非农 */
   householdType?: string
   districtId?: string
@@ -58,12 +67,35 @@ export interface Person {
   inputVillageId?: string
   /** 死亡日期 yyyy-MM-dd */
   deathDate?: string
+  /** 死亡状态:正常/已死亡(大数据匹配或死亡登记) */
+  deathStatus?: string
   heirName?: string
   /** 继承人与逝者关系 */
   heirRelation?: string
   heirIdNumber?: string
   heirBankCard?: string
   heirPhone?: string
+  personType?: string
+  urbanMedicareConfirm?: string
+  householdDistrict?: string
+  householdTown?: string
+  householdVillage?: string
+  contactName?: string
+  contactPhone?: string
+  insuranceJoinDate?: string
+  personMark?: string
+  changeReason?: string
+  benefitApplyDate?: string
+  sentenceStart?: string
+  sentenceEnd?: string
+  cancelApplyDate?: string
+  cancelReason?: string
+  cancelDeathMonth?: string
+  hasHeir?: string
+  heirIdType?: string
+  heirBankName?: string
+  heirHouseholdAddress?: string
+  heirAddress?: string
   funeralDiffAmount?: number
   landSubsidyIncrease?: number
   createTime?: string
@@ -280,6 +312,18 @@ export interface Approval {
   amount?: number
   heirName?: string
   idCard?: string
+  householdType?: string
+  startMonth?: string
+  /** 新增时间:村提交资格的时间 */
+  addedAt?: string
+  insuranceStatus?: string
+  /** 居保失败原因,如非农保退休;通过时为空 */
+  insuranceReason?: string
+  standard?: number
+  remark?: string
+  retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
+  deathWarning?: boolean
+  deathStatus?: string
 }
 
 export interface SpecialBiz {
@@ -395,6 +439,12 @@ export interface DeathRecord {
   heirBankCard?: string
   /** 继承人联系方式 */
   heirContact?: string
+  heirRelation?: string
+  heirIdType?: string
+  heirBankName?: string
+  heirHouseholdAddress?: string
+  heirAddress?: string
+  cancelApplyDate?: string
 }
 
 /** 告警生命周期节点 */
@@ -579,6 +629,14 @@ export interface DashboardData {
   townVillageAmountChart?: ChartBarItem[]
   /** 镇经发中心:本批次各村发放人数 */
   townVillagePeopleChart?: ChartBarItem[]
+  /** 镇/村展板:本年各月发放人数 */
+  monthPeopleChart?: ChartBarItem[]
+  /** 镇/村展板:本年各月发放金额 */
+  monthAmountChart?: ChartBarItem[]
+  /** 镇/村展板:近三年发放人数 */
+  yearPeopleChart?: ChartBarItem[]
+  /** 镇/村展板:近三年发放金额 */
+  yearAmountChart?: ChartBarItem[]
   /** 镇经发中心:土地退养资格初审 */
   townReview?: TownReviewData
   /** 镇社区事务受理中心:业务办理概览与操作提示 */

+ 14 - 3
src/utils/appConfig.ts

@@ -47,7 +47,7 @@ const fallbackConfig: AppConfig = {
   requestTimeout: 15000,
   apiBase: '/api',
   useMock: false,
-  currentRole: 'admin',
+  currentRole: 'social',
   currentMonth: '2026-08',
   roles: [
     {
@@ -74,7 +74,7 @@ const fallbackConfig: AppConfig = {
     },
     {
       key: 'leader',
-      label: '分管领导',
+      label: '镇社区受理中心分管领导',
       org: '叶榭镇社区事务受理中心',
       allData: false,
       town: '叶榭镇',
@@ -98,7 +98,7 @@ const fallbackConfig: AppConfig = {
     ],
     leader: ['/dashboard', '/approvals', '/reports', '/alerts'],
     agri: ['/dashboard', '/reports'],
-    hr: ['/dashboard', '/reports', '/alerts'],
+    hr: ['/dashboard', '/reports', '/alerts', '/status'],
     admin: [
       '/dashboard',
       '/base',
@@ -115,6 +115,17 @@ const fallbackConfig: AppConfig = {
   alertReadonlyRoles: ['hr', 'leader'],
 }
 
+/** 控制角色下拉是否显示系统管理员:localStorage.sj_tdtybz_show_admin=1 */
+export const SHOW_ADMIN_ROLE_KEY = 'sj_tdtybz_show_admin'
+
+export function isAdminRoleVisible(): boolean {
+  try {
+    return localStorage.getItem(SHOW_ADMIN_ROLE_KEY) === '1'
+  } catch {
+    return false
+  }
+}
+
 /** 读取全局配置(合并 window.AppConfig) */
 export function getAppConfig(): AppConfig {
   return {

+ 10 - 10
src/utils/buildDashboard.ts

@@ -1,5 +1,5 @@
 /**
- * 工作台数据按角色构建(对齐现行:资格审批 + 每月 1 号全区月批)
+ * 工作台数据按角色构建(对齐现行:资格审批 + 每月 15 号全区月批)
  *
  * 角色差异:
  * - 数据范围:allData=全区;有 village=本村;仅有 town=本镇
@@ -47,15 +47,15 @@ export const VILLAGE_WORKBENCH_TIPS = [
   },
   {
     title: '提交资格',
-    desc: '材料齐全后点「提交资格」,交镇经发按人审批。通过后为「新增」,等待下个 1 号纳入全区月发。',
+    desc: '材料齐全后点「提交资格」,交镇经发按人审批。通过后为「新增」,等待下个 15 号纳入全区月发。',
   },
   {
     title: '驳回后重提',
     desc: '镇经发驳回补材料后回到「待提交资格」,核验身份证、银行卡、退养材料后再提交。认定无资格则进入异常待处理。',
   },
   {
-    title: '每月 1 号纳入',
-    desc: '1 号系统生成全区月批。「新增」与正常发放人员自动进当月。1 号后新录入默认进下月;本月要发须由镇社区事务走特殊业务。',
+    title: '每月 15 号纳入',
+    desc: '15 号系统生成全区月批。「新增」与正常发放人员自动进当月。15 号后新录入默认进下月;本月要发须由镇社区事务走特殊业务。',
   },
   {
     title: '告警与关键信息',
@@ -82,13 +82,13 @@ export const TOWN_REVIEW_TIPS = [
   },
   {
     title: '职责边界',
-    desc: '通过后人员为「新增」,等每月 1 号纳入全区月发。不办理居保、月度清单、出盘回盘。',
+    desc: '通过后人员为「新增」,等每月 15 号纳入全区月发。不办理居保、月度清单、出盘回盘。',
   },
 ]
 
 export const SOCIAL_WORKBENCH_TIPS = [
   {
-    title: '每月 1 号全区月批',
+    title: '每月 15 号全区月批',
     desc: '系统生成当月一条全区批次并自动居保。本中心只办本镇切片,看不见其他镇明细。',
   },
   {
@@ -97,7 +97,7 @@ export const SOCIAL_WORKBENCH_TIPS = [
   },
   {
     title: '特殊业务门禁',
-    desc: '死亡、丧葬、停发/恢复、调标补发、多发退款须分管领导通过。未全部通过前,不可提交本镇月度清单。1 号后新人要进本月发放,也须经特殊业务加人。',
+    desc: '死亡、丧葬、停发/恢复、调标补发、多发退款须分管领导通过。未全部通过前,不可提交本镇月度清单。15 号后新人要进本月发放,也须经特殊业务加人。',
   },
   {
     title: '清单、出盘、回盘',
@@ -139,7 +139,7 @@ export const AGRI_WORKBENCH_TIPS = [
   },
   {
     title: '资格主线',
-    desc: '村合作社录入并提交资格 → 镇经发按人审批 →「新增」等待每月 1 号纳入全区月发。',
+    desc: '村合作社录入并提交资格 → 镇经发按人审批 →「新增」等待每月 15 号纳入全区月发。',
   },
   {
     title: '不办理发放',
@@ -158,7 +158,7 @@ export const HR_WORKBENCH_TIPS = [
   },
   {
     title: '发放主线',
-    desc: '每月 1 号全区一批次并居保 → 镇社区事务核验清单 → 分管领导审批 → 出盘回盘归档。',
+    desc: '每月 15 号全区一批次并居保 → 镇社区事务核验清单 → 分管领导审批 → 出盘回盘归档。',
   },
   {
     title: '关注异常',
@@ -173,7 +173,7 @@ export const HR_WORKBENCH_TIPS = [
 export const ADMIN_WORKBENCH_TIPS = [
   {
     title: '全区月批运维',
-    desc: '每月 1 号 01:00 自动生成 DB{yyyyMM} 并居保。若当日已过 1 号,可在「批次管理」手动生成/重跑。',
+    desc: '每月 15 号 01:00 自动生成 DB{yyyyMM} 并居保。若当日已过 15 号,可在「批次管理」手动生成/重跑。',
   },
   {
     title: '不替代业务审批',

+ 18 - 0
src/utils/deathWarning.ts

@@ -0,0 +1,18 @@
+/** 死亡预警:仅提示,不改变人员业务状态。已进入丧葬/退出的不再打预警标签。 */
+export function isDeathWarningPerson(p?: {
+  deathStatus?: string
+  deathDate?: string
+  bizStatus?: string
+  status?: string
+  deathWarning?: boolean
+} | null): boolean {
+  if (!p) return false
+  if (p.deathWarning === true) return true
+  const biz = String(p.bizStatus || '')
+  const status = String(p.status || '')
+  if (biz === 'FUNERAL' || biz === 'EXITED' || status.includes('死亡丧葬') || status === '已退出') {
+    return false
+  }
+  if (String(p.deathStatus || '').trim() === '已死亡') return true
+  return Boolean(String(p.deathDate || '').trim())
+}

+ 57 - 2
src/utils/statusLabels.ts

@@ -181,7 +181,7 @@ const ROLE_LABEL: Record<string, string> = {
   village: '村合作社',
   town: '镇经发中心',
   social: '镇社区事务受理中心',
-  leader: '分管领导',
+  leader: '镇社区受理中心分管领导',
   agri: '区农委',
   hr: '区人社局',
   admin: '系统管理员',
@@ -189,7 +189,7 @@ const ROLE_LABEL: Record<string, string> = {
   unknown: '未知角色',
   系统: '系统',
   业务部门: '业务部门',
-  分管领导: '分管领导',
+  分管领导: '镇社区受理中心分管领导',
   历史: '历史',
 }
 
@@ -250,6 +250,28 @@ export function personnelStatusLabel(code?: string | null) {
   return labelOf('personnelStatus', code)
 }
 
+/** 户籍性质:农业 / 非农。外来码转中文,已是中文则原样 */
+export function householdTypeLabel(code?: string | null): string {
+  const s = String(code || '').trim()
+  if (!s) return '—'
+  const upper = s.toUpperCase()
+  if (upper === 'AGRI' || upper === 'AGRICULTURAL' || upper === 'RURAL' || s === '农业户口' || s === '农村') {
+    return '农业'
+  }
+  if (
+    upper === 'NON_AGRI' ||
+    upper === 'NONAGRI' ||
+    upper === 'URBAN' ||
+    s === '非农业' ||
+    s === '非农户口'
+  ) {
+    return '非农'
+  }
+  if (s.includes('非')) return '非农'
+  if (s.includes('农')) return '农业'
+  return s
+}
+
 /** 退养资格类型展示:空/UNSET → 待认定 */
 export function retireQualifyLabel(code?: string | null) {
   const s = String(code || '').trim()
@@ -312,6 +334,39 @@ export function roleLabelOf(code?: string | null): string {
   return raw
 }
 
+/** 把正文里夹带的英文业务码换成中文(告警内容等) */
+export function sanitizeBizCodesInText(text?: string | null): string {
+  if (text == null || text === '') return ''
+  return String(text).replace(/\b([A-Z][A-Z0-9_]{2,})\b/g, (code) => {
+    return (
+      tryLabelOf('batchStatus', code) ||
+      tryLabelOf('flowStage', code) ||
+      tryLabelOf('personnelStatus', code) ||
+      tryLabelOf('alertType', code) ||
+      tryLabelOf('alertHandleStatus', code) ||
+      tryLabelOf('insuranceMatch', code) ||
+      tryLabelOf('specialBizType', code) ||
+      tryLabelOf('processAction', code) ||
+      code
+    )
+  })
+}
+
+/** 备注/意见展示:去掉内部标记和区划编码,英文业务码转中文 */
+export function displayRemark(text?: string | null): string {
+  if (text == null || text === '') return '—'
+  let s = String(text)
+  s = s.replace(/MOCK_SEED_\d+\|?/gi, ' ')
+  s = s.replace(/FAIL_CNT[=|]\d+/gi, ' ')
+  s = s.replace(/SUBMIT_TIME\|\d+/gi, ' ')
+  s = s.replace(/PROFILE_SYNC=(?:PENDING|DONE)/gi, ' ')
+  s = s.replace(/FILES:/gi, ' ')
+  s = s.replace(/\b[TDV]_[A-Z0-9_]+\b/g, ' ')
+  s = sanitizeBizCodesInText(s)
+  s = s.replace(/\|+/g, ' ').replace(/\s+/g, ' ').trim()
+  return s || '—'
+}
+
 /** 通用展示:优先中文,疑似英文码则尝试多字典转义 */
 export function displayBizText(code?: string | null): string {
   if (code == null || code === '') return '—'

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

@@ -6,11 +6,11 @@
           生成/重跑本月全区批次
         </el-button>
         <el-button type="danger" size="small" :loading="generating" @click="onGenerateDistrict(true)">
-          联调:按1号纳入新增
+          联调:按15号纳入新增
         </el-button>
       </template>
       <p class="page-tip">
-        每月 1 号生成一份全区月批,各镇是统计切片(不是一村一批)。村/镇角色在各自页面按范围看人,不单独建村级批次。联调测本月发放请点「按1号纳入新增」。
+        每月 15 号生成一份全区月批,各镇是统计切片(不是一村一批)。村/镇角色在各自页面按范围看人,不单独建村级批次。联调测本月发放请点「按15号纳入新增」。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -247,9 +247,9 @@ async function onGenerateDistrict(simulateFirstDay = false) {
   try {
     await ElMessageBox.confirm(
       simulateFirstDay
-        ? '联调模拟每月 1 号:将把「新增」且已有资格人员的下次发放起始月改到本月,再按正式规则生成全区批并居保。正式 1 号规则不变。'
+        ? '联调模拟每月 15 号:将把「新增」且已有资格人员的下次发放起始月改到本月,再按正式规则生成全区批并居保。正式 15 号规则不变。'
         : '确认生成或重跑本月全区批次?将按规则纳入人员、刷新各镇统计并触发居保匹配。已有明细不会重复加倍补发/扣款。人员较多时可能需要数分钟,请勿关闭页面。',
-      simulateFirstDay ? '联调:按1号纳入新增' : '生成全区月批',
+      simulateFirstDay ? '联调:按15号纳入新增' : '生成全区月批',
       { type: 'warning', confirmButtonText: simulateFirstDay ? '确认纳入并生成' : '确认生成' },
     )
   } catch {
@@ -264,7 +264,7 @@ async function onGenerateDistrict(simulateFirstDay = false) {
     const pulled = Number(res?.newPulled || 0)
     if (n <= 0) {
       ElMessage.warning(
-        String(res?.message || '本月无人可纳入发放。资格新通过的「新增」人员等下月 1 号。'),
+        String(res?.message || '本月无人可纳入发放。资格新通过的「新增」人员等下月 15 号。'),
       )
     } else {
       ElMessage.success(

+ 17 - 3
src/views/admin/PersonnelAdminView.vue

@@ -72,6 +72,9 @@
         <el-table-column type="selection" width="42" align="center" reserve-selection />
         <el-table-column prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column label="户籍性质" width="88" align="center">
+          <template #default="{ row }">{{ householdTypeLabel(row.householdType) }}</template>
+        </el-table-column>
         <el-table-column prop="name" label="姓名" width="88" />
         <el-table-column label="身份证号" min-width="160">
           <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
@@ -171,8 +174,15 @@
         <el-form-item label="本月发放">
           <el-switch v-model="statusForm.payThisMonth" />
         </el-form-item>
-        <el-form-item label="备注">
-          <el-input v-model="statusForm.remarks" type="textarea" :rows="2" />
+        <el-form-item label="变更原因" required>
+          <el-input
+            v-model="statusForm.remarks"
+            type="textarea"
+            :rows="2"
+            maxlength="200"
+            show-word-limit
+            placeholder="必填,写入流程日志,说明为何改状态"
+          />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -262,7 +272,7 @@ import { getAppConfig } from '@/utils/appConfig'
 import { getVillagesByTown } from '@/api/mock/data'
 import { maskIdCard } from '@/utils/mask'
 import { explainIdCard, isValidPhoneOptional } from '@/utils/validators'
-import { personnelStatusLabel, retireQualifyLabel } from '@/utils/statusLabels'
+import { householdTypeLabel, personnelStatusLabel, retireQualifyLabel } from '@/utils/statusLabels'
 import type { Person } from '@/types/biz'
 
 const {
@@ -482,6 +492,10 @@ function openStatus(row: Person) {
 
 async function saveStatus() {
   if (!current.value) return
+  if (!String(statusForm.value.remarks || '').trim()) {
+    ElMessage.warning('请填写变更原因。人员状态变更必须留下可追溯说明')
+    return
+  }
   const zh = personnelStatusLabel(statusForm.value.bizStatus)
   try {
     await ElMessageBox.confirm(

+ 252 - 47
src/views/alerts/AlertsView.vue

@@ -2,9 +2,9 @@
   <div class="page-fill alerts-view">
     <PagePanel title="监控告警">
       <p class="page-tip">{{ tipText }}</p>
-      <div v-if="isAdmin" class="query-panel alerts-view__filter">
+      <div class="query-panel alerts-view__filter">
         <el-form :inline="true" size="small">
-          <el-form-item label="街镇">
+          <el-form-item v-if="isAdmin" label="街镇">
             <el-select
               v-model="filterTown"
               placeholder="全部"
@@ -15,11 +15,38 @@
               <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
             </el-select>
           </el-form-item>
-          <el-form-item label="村居">
+          <el-form-item v-if="isAdmin" label="村居">
             <el-select v-model="filterVillage" placeholder="全部" clearable style="width: 140px">
               <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
             </el-select>
           </el-form-item>
+          <el-form-item label="人员">
+            <el-input v-model="filterName" placeholder="姓名" clearable style="width: 120px" />
+          </el-form-item>
+          <el-form-item v-if="!isVillageRole && !isTownRole" label="类型">
+            <el-select v-model="filterType" placeholder="全部" clearable style="width: 140px">
+              <el-option label="回盘失败" value="RETURN_FAIL" />
+              <el-option label="审批超时" value="APPROVAL_TIMEOUT" />
+              <el-option label="死亡停发预警" value="DEATH_STATUS" />
+              <el-option label="居保匹配失败" value="INSURANCE_MATCH_FAIL" />
+              <el-option label="退养资格驳回" value="QUALIFY_REJECT" />
+              <el-option label="材料待补" value="MATERIAL_PENDING" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="等级">
+            <el-select v-model="filterLevel" placeholder="全部" clearable style="width: 100px">
+              <el-option label="高" value="HIGH" />
+              <el-option label="中" value="MEDIUM" />
+              <el-option label="低" value="LOW" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-select v-model="filterStatus" placeholder="全部" clearable style="width: 110px">
+              <el-option label="待处理" value="PENDING" />
+              <el-option label="处理中" value="PROCESSING" />
+              <el-option label="已关闭" value="CLOSED" />
+            </el-select>
+          </el-form-item>
           <el-form-item>
             <el-button type="primary" @click="applyFilter">查询</el-button>
             <el-button @click="resetFilter">重置</el-button>
@@ -28,14 +55,18 @@
       </div>
       <div class="page-fill__table">
       <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
-        <el-table-column prop="batchNo" label="批次号" min-width="120" align="center" show-overflow-tooltip />
-        <el-table-column prop="personName" label="人员" width="90" />
-        <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column label="人员" width="160" fixed>
+          <template #default="{ row }">
+            <PersonNameCell
+              :name="displayAlertPerson(row as AlertItem)"
+              :death-warning="isDeathAlertType(row.type)"
+            />
+          </template>
+        </el-table-column>
         <el-table-column prop="type" label="告警类型" width="120">
           <template #default="{ row }">
             <span :class="{ 'alerts-view__danger-type': isDeathAlertType(row.type) }">
-              {{ row.type }}
+              {{ alertTypeLabel(row.type) }}
             </span>
           </template>
         </el-table-column>
@@ -44,12 +75,26 @@
             <StatusTag :status="row.level" mode="level" />
           </template>
         </el-table-column>
-        <el-table-column prop="content" label="告警内容" min-width="200" show-overflow-tooltip />
+        <el-table-column label="告警内容" min-width="200" show-overflow-tooltip>
+          <template #default="{ row }">{{ alertContentText(row as AlertItem) }}</template>
+        </el-table-column>
+        <el-table-column prop="town" label="街镇" width="90" />
+        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column
+          v-if="!isVillage"
+          prop="batchNo"
+          label="批次号"
+          width="100"
+          align="center"
+          show-overflow-tooltip
+        />
         <el-table-column label="状态" width="88" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.status" />
           </template>
         </el-table-column>
+        <el-table-column prop="handler" label="处理人" width="100" show-overflow-tooltip />
+        <el-table-column prop="handleTime" label="处理时间" width="140" />
         <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="160" align="center" fixed="right">
@@ -58,7 +103,16 @@
               详情
             </el-button>
             <el-button
-              v-if="!userStore.alertReadonly && row.status !== '已关闭'"
+              v-if="isBatchLevelAlertType((row as AlertItem).type) && row.status !== '已关闭'"
+              link
+              type="warning"
+              size="small"
+              @click="goApproveBatch(row as AlertItem)"
+            >
+              去审批
+            </el-button>
+            <el-button
+              v-else-if="!userStore.alertReadonly && row.status !== '已关闭'"
               link
               type="warning"
               size="small"
@@ -83,9 +137,9 @@
     >
       <p v-if="currentAlert" class="alerts-view__dialog-meta">
         告警类型:<strong :class="{ 'alerts-view__danger-type': isDeathAlertType(currentAlert.type) }">
-          {{ currentAlert.type }}
+          {{ alertTypeLabel(currentAlert.type) }}
         </strong>
-        · 批次 {{ currentAlert.batchNo }} · {{ currentAlert.content }}
+        · 批次 {{ currentAlert.batchNo }} · {{ alertContentText(currentAlert) }}
       </p>
       <el-alert
         v-if="isDiskFailAlert"
@@ -105,11 +159,30 @@
           <li v-for="item in diskFailReasons" :key="item">{{ item }}</li>
         </ul>
       </el-alert>
-      <p class="alerts-view__dialog-tip">
-        请核实并修正人员信息后提交(展示人员表全部字段)。姓名、身份证号、街镇、村居、银行卡号为必填。涉及丧葬/死亡类告警时请登记继承人信息。处理备注建议填写,证明材料可上传。提交后告警将关闭,村/镇状态同步更新。
-      </p>
-      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" size="small">
+      <el-alert
+        v-if="isBatchLevelAlert"
+        type="info"
+        :closable="false"
+        show-icon
+        class="alerts-view__fail-reason-alert"
+        title="本条为批次级告警,不关联具体人员"
+      >
+        请到「审批管理」完成该批次审批。此处只需登记催办/核实结果后关闭,不必改人员档案。
+      </el-alert>
+      <el-alert
+        v-else-if="currentAlert && isDeathAlertType(currentAlert.type)"
+        type="warning"
+        :closable="false"
+        show-icon
+        class="alerts-view__fail-reason-alert"
+        title="死亡预警只提示,不自动停发"
+      >
+        请核实死亡信息。确认后再办理停发或死亡登记。关闭本告警不会改发放状态。
+      </el-alert>
+      <p v-else class="alerts-view__dialog-tip">{{ handleDialogTip }}</p>
+      <el-form ref="formRef" :model="form" :rules="isBatchLevelAlert ? {} : formRules" label-width="140px" size="small">
         <PersonnelFormFields
+          v-if="!isBatchLevelAlert"
           :model="form"
           mode="edit"
           :town-options="townOptions"
@@ -157,7 +230,7 @@
     <!-- 告警详情 + 处理流程时间线 -->
     <el-dialog
       v-model="detailVisible"
-      :title="detailAlert ? `告警详情 · ${detailAlert.personName || detailAlert.id}` : '告警详情'"
+      :title="detailAlert ? `告警详情 · ${displayAlertPerson(detailAlert)}` : '告警详情'"
       width="720px"
       destroy-on-close
       @closed="onDetailClosed"
@@ -168,13 +241,21 @@
           <el-descriptions-item label="状态">
             <StatusTag :status="detailAlert.status" />
           </el-descriptions-item>
-          <el-descriptions-item label="类型">{{ detailAlert.type }}</el-descriptions-item>
+          <el-descriptions-item label="类型">{{ alertTypeLabel(detailAlert.type) }}</el-descriptions-item>
           <el-descriptions-item label="等级">
             <StatusTag :status="detailAlert.level" mode="level" />
           </el-descriptions-item>
+          <el-descriptions-item label="人员">
+            <PersonNameCell
+              :name="displayAlertPerson(detailAlert)"
+              :death-warning="isDeathAlertType(detailAlert.type)"
+            />
+          </el-descriptions-item>
+          <el-descriptions-item label="处理人">{{ detailAlert.handler || '—' }}</el-descriptions-item>
           <el-descriptions-item label="街镇/村居">
             {{ detailAlert.town }} / {{ detailAlert.village }}
           </el-descriptions-item>
+          <el-descriptions-item label="处理时间">{{ detailAlert.handleTime || '—' }}</el-descriptions-item>
           <el-descriptions-item label="产生时间">{{ detailAlert.createdAt || '—' }}</el-descriptions-item>
           <el-descriptions-item label="告警内容" :span="2">
             {{ detailAlert.content || '—' }}
@@ -191,7 +272,14 @@
       </template>
       <template #footer>
         <el-button
-          v-if="detailAlert && !userStore.alertReadonly && detailAlert.status !== '已关闭'"
+          v-if="detailAlert && isBatchLevelAlertType(detailAlert.type) && detailAlert.status !== '已关闭'"
+          type="warning"
+          @click="goApproveBatch(detailAlert)"
+        >
+          去审批
+        </el-button>
+        <el-button
+          v-else-if="detailAlert && !userStore.alertReadonly && detailAlert.status !== '已关闭'"
           type="warning"
           @click="openHandleFromDetail"
         >
@@ -205,7 +293,7 @@
 
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import type { FormInstance, FormRules, UploadFile, UploadUserFile } from 'element-plus'
 import { ElMessage } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
@@ -214,10 +302,11 @@ import TablePagination from '@/components/common/TablePagination.vue'
 import AlertLifecycleTimeline from '@/components/biz/AlertLifecycleTimeline.vue'
 import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
 import {
   fetchAlertDetail,
   fetchAlertsPage,
-  fetchPeople,
+  fetchPeoplePage,
   fetchPersonDetail,
   handleAlert,
   savePerson,
@@ -236,10 +325,19 @@ import {
 } from '@/constants/diskReturn'
 import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
 import { explainIdCard } from '@/utils/validators'
+import { labelOf, sanitizeBizCodesInText } from '@/utils/statusLabels'
 import type { AlertItem, AlertLifecycleItem, Person } from '@/types/biz'
 
 const userStore = useUserStore()
+const router = useRouter()
+
+function alertTypeLabel(type?: string) {
+  return labelOf('alertType', type) || '—'
+}
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
+const isVillage = computed(() => Boolean(userStore.roleInfo?.village) || userStore.roleInfo?.key === 'village')
+const isVillageRole = computed(() => userStore.currentRole === 'village')
+const isTownRole = computed(() => userStore.currentRole === 'town')
 const {
   filterTown,
   filterVillage,
@@ -249,6 +347,11 @@ const {
   resetTownVillage,
 } = useTownVillageOptions()
 
+const filterName = ref('')
+const filterType = ref('')
+const filterLevel = ref('')
+const filterStatus = ref('')
+
 const {
   page,
   pageSize,
@@ -260,7 +363,20 @@ const {
   const role = userStore.roleInfo
   const town = isAdmin.value ? filterTown.value || undefined : role?.town
   const village = isAdmin.value ? filterVillage.value || undefined : role?.village
-  return fetchAlertsPage({ town, village, pageNum, pageSize })
+  return fetchAlertsPage({
+    town,
+    village,
+    name: filterName.value || undefined,
+    type: isVillageRole.value
+      ? 'QUALIFY_REJECT'
+      : isTownRole.value
+        ? 'APPROVAL_TIMEOUT'
+        : filterType.value || undefined,
+    status: filterStatus.value || undefined,
+    level: filterLevel.value || undefined,
+    pageNum,
+    pageSize,
+  })
 })
 const list = pagedList
 
@@ -270,6 +386,10 @@ function applyFilter() {
 
 function resetFilter() {
   resetTownVillage()
+  filterName.value = ''
+  filterType.value = ''
+  filterLevel.value = ''
+  filterStatus.value = ''
   void loadData()
 }
 
@@ -308,11 +428,61 @@ function isDeathAlertType(type?: string) {
   return type === '死亡停发预警' || type === '民政死亡比对' || type === 'DEATH_STATUS'
 }
 
+function alertContentText(row?: { type?: string; content?: string }) {
+  const text = String(row?.content || '')
+  if (isDeathAlertType(row?.type) && text.includes('停止继续发放')) {
+    return '死亡信息比对预警,请核实确认后再停发,系统不自动停发'
+  }
+  return sanitizeBizCodesInText(text)
+}
+
+/** 审批超时不在告警里关单,直接去审批管理打开这一批次。 */
+function goApproveBatch(row: AlertItem) {
+  const batchNo = String(row.batchNo || '').trim()
+  if (!batchNo) {
+    ElMessage.warning('这条超时告警没有批次号,无法定位审批单')
+    return
+  }
+  if (userStore.currentRole === 'town') {
+    ElMessage.warning(
+      `批次 ${batchNo} 待分管领导审批。镇经发的审批页是退养资格,审不了这张月清单。请用分管领导角色打开审批管理。`,
+    )
+    return
+  }
+  if (!userStore.canAccess('/approvals')) {
+    ElMessage.warning(`批次 ${batchNo} 待分管领导审批,请由镇社区受理中心分管领导在「审批管理」办理。`)
+    return
+  }
+  detailVisible.value = false
+  dialogVisible.value = false
+  router.push({ path: '/approvals', query: { batchNo } })
+}
+
+function isBatchLevelAlertType(type?: string) {
+  return type === '审批超时' || type === 'APPROVAL_TIMEOUT'
+}
+
+function looksLikeBatchToken(s?: string, batchNo?: string) {
+  const t = String(s || '').trim()
+  if (!t) return true
+  if (batchNo && t === batchNo) return true
+  return /^(TB|DB|VB|MB)/i.test(t) || /^\d{6}$/.test(t)
+}
+
+function displayAlertPerson(row?: AlertItem | null) {
+  if (!row || isBatchLevelAlertType(row.type) || looksLikeBatchToken(row.personName, row.batchNo)) {
+    return '—'
+  }
+  return row.personName || '—'
+}
+
 const isDiskFailAlert = computed(() => {
   const t = currentAlert.value?.type
   return t === '回盘失败' || t === '回盘发放失败' || t === 'RETURN_FAIL'
 })
 
+const isBatchLevelAlert = computed(() => isBatchLevelAlertType(currentAlert.value?.type))
+
 const form = ref(emptyPersonForm())
 
 function onAlertFormUpdate(v: Person) {
@@ -397,8 +567,11 @@ const tipText = computed(() => {
   if (role?.key === 'hr') {
     return '全区告警只读(不可关闭)。含居保失败、回盘失败、死亡比对等,由镇社区事务或村合作社处理。'
   }
-  if (role?.village) {
-    return `仅本村(${role.town || ''}${role.village})告警,与镇社区事务共用;任一方处理后即时同步。常见:退养资格驳回、居保失败、回盘失败、死亡比对。`
+  if (role?.key === 'village') {
+    return `仅本村(${role.town || ''}${role.village || ''})退养资格驳回。材料不全时补齐后关闭。回盘、居保、死亡等告警由镇社区事务受理中心办理。`
+  }
+  if (role?.key === 'town') {
+    return `仅本镇(${role.town || ''})审批超时。请到审批管理催办。回盘、居保、死亡等告警由镇社区事务受理中心办理。`
   }
   if (role?.key === 'social') {
     return `本镇(${role.town || ''})各村告警,与对应村共用。居保失败须先在「居保状态匹配」人工确认,再关闭告警。`
@@ -409,17 +582,56 @@ const tipText = computed(() => {
   return '含民政死亡比对、居保匹配失败、回盘发放失败等。区人社局可查看、不可关闭。'
 })
 
+const handleDialogTip = computed(() => {
+  const role = userStore.roleInfo?.key
+  if (role === 'village') {
+    return '请按驳回意见补齐退养资格材料后关闭。处理备注必填。'
+  }
+  if (role === 'social') {
+    return '请核实人员档案与发放信息。居保失败须先在「居保状态匹配」人工确认;回盘失败请登记原因。涉及丧葬/死亡时请登记继承人。处理备注必填。'
+  }
+  if (role === 'admin') {
+    return '运维可改街镇村居与档案字段。姓名、身份证、街镇、村居、银行卡必填。提交后告警关闭。'
+  }
+  return '请核实人员信息后提交。处理备注必填,证明材料可上传。提交后告警关闭。'
+})
+
 function resetForm() {
   form.value = emptyPersonForm()
 }
 
+async function resolveAlertPerson(row: AlertItem): Promise<Person | undefined> {
+  if (isBatchLevelAlertType(row.type)) return undefined
+  const pid = String(row.personnelId || '').trim()
+  if (pid && !looksLikeBatchToken(pid, row.batchNo)) {
+    const byId = await fetchPersonDetail(pid, { silent: true })
+    if (byId) return byId
+  }
+  const name = displayAlertPerson(row)
+  if (!name || name === '—') return undefined
+  const page = await fetchPeoplePage({
+    town: row.town,
+    village: row.village,
+    name,
+    pageNum: 1,
+    pageSize: 20,
+  })
+  const matched = (page.list || []).find(
+    (p) => p.name === name && (!row.town || p.town === row.town) && (!row.village || p.village === row.village),
+  )
+  if (matched?.id) {
+    return (await fetchPersonDetail(String(matched.id), { silent: true })) || matched
+  }
+  return matched
+}
+
 async function openHandleDialog(row: AlertItem) {
   currentAlert.value = row
   handleRemarks.value = ''
   handleFileList.value = []
   handleRawFiles.value = []
   form.value = emptyPersonForm({
-    name: row.personName || '',
+    name: isBatchLevelAlertType(row.type) ? '' : row.personName || '',
     town: row.town || '',
     village: row.village || '',
     idCard: row.idCard || '',
@@ -432,21 +644,11 @@ async function openHandleDialog(row: AlertItem) {
     heirPhone: row.heirContact || '',
   })
 
-  // 从人员档案回填全字段
+  dialogVisible.value = true
+  if (isBatchLevelAlertType(row.type)) return
+
   try {
-    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
-      }
-    }
+    const matched = await resolveAlertPerson(row)
     if (matched) {
       form.value = personToForm({
         ...matched,
@@ -460,8 +662,6 @@ async function openHandleDialog(row: AlertItem) {
   } catch {
     /* 回填失败不影响打开弹窗 */
   }
-
-  dialogVisible.value = true
 }
 
 function onDialogClosed() {
@@ -513,8 +713,11 @@ function openHandleFromDetail() {
 
 async function onSubmitHandle() {
   if (!currentAlert.value) return
-  const valid = await formRef.value?.validate().catch(() => false)
-  if (!valid) return
+  const batchLevel = isBatchLevelAlertType(currentAlert.value.type)
+  if (!batchLevel) {
+    const valid = await formRef.value?.validate().catch(() => false)
+    if (!valid) return
+  }
   if (!handleRemarks.value.trim()) {
     ElMessage.warning('请填写处理备注')
     return
@@ -525,7 +728,7 @@ async function onSubmitHandle() {
   try {
     const f = form.value
     // 先落库全字段人员信息(有人员ID时);版本戳必带,避免无锁覆盖
-    if (f.id) {
+    if (!batchLevel && f.id) {
       if (f.updateTimeMs == null) {
         ElMessage.warning('人员版本信息缺失,请关闭后重新打开再处理')
         return
@@ -564,9 +767,11 @@ async function onSubmitHandle() {
     })
     const qualifyReject = String(currentAlert.value.type || '').includes('退养资格')
     ElMessage.success(
-      qualifyReject
-        ? '告警已关闭,人员已退回待提交资格,可重新提交审批'
-        : '告警已处理并关闭(人员与继承人信息已保存,村/镇状态已同步)',
+      batchLevel
+        ? '告警已处理并关闭'
+        : qualifyReject
+          ? '告警已关闭,人员已退回待提交资格,可重新提交审批'
+          : '告警已处理并关闭(人员与继承人信息已保存,村/镇状态已同步)',
     )
     dialogVisible.value = false
     await loadData()

+ 426 - 72
src/views/approvals/ApprovalsView.vue

@@ -1,22 +1,31 @@
 <template>
   <div class="page-fill approvals-view">
     <PagePanel :title="pageTitle">
-      <template v-if="!isHistoryTab" #extra>
+      <template #extra>
+        <template v-if="!isHistoryTab">
+          <el-button
+            type="primary"
+            :disabled="!selectedIds.length"
+            @click="onBatchDecide(true)"
+          >
+            批量通过
+          </el-button>
+          <el-button :disabled="!selectedIds.length" @click="onBatchDecide(false)">
+            批量驳回
+          </el-button>
+        </template>
         <el-button
-          type="primary"
-          :disabled="!selectedIds.length"
-          @click="onBatchDecide(true)"
+          v-if="isTownReview"
+          :loading="newMonthExporting"
+          @click="onExportNewMonth"
         >
-          批量通过
-        </el-button>
-        <el-button :disabled="!selectedIds.length" @click="onBatchDecide(false)">
-          批量驳回
+          导出新增名单
         </el-button>
       </template>
 
       <p class="page-tip">
         <template v-if="isHistoryTab && isTownReview">
-          已办结的资格审批。通过为「新增」(等 1 号纳入月发),无资格为异常待处理,补材料退回「待提交资格」。
+          已审记录按月名单表展示。通过为「新增」(等 15 号纳入月发),无资格为异常待处理,补材料退回「待提交资格」。可导出纸质新增名单。
         </template>
         <template v-else-if="isHistoryTab && isLeaderReview">
           已办结的本镇月度清单与特殊业务审批。清单通过后社区事务可出盘;驳回后退回社区事务继续办理。
@@ -25,13 +34,13 @@
           已办结审批,可按条件筛选查看,不可再次审批。
         </template>
         <template v-else-if="isTownReview">
-          按人审批本镇退养资格(不看见批次)。待审只含村合作社已点「提交资格」的人员;未提交的请到「人员新增录入」办理。通过后为「新增」等 1 号纳入;已在正常发放的只补登记资格、不改发放状态。
+          按月名单表审批本次新增人员。村级随时加入,镇经发随时通过或驳回。资格以村级勾选为准,不改已有在册名单。通过后为「新增」,等 15 号纳入月发。
         </template>
         <template v-else-if="isLeaderReview">
           审本镇月度清单(镇统计)与特殊业务。通过后社区事务可出盘。提交到本节点前,本镇当月特殊业务须全部通过。
         </template>
         <template v-else>
-          管理员可按街镇协查:镇经发审退养资格;分管领导审本镇月度清单与特殊业务。日常审批由对应镇办理。
+          管理员可按街镇协查:镇经发审退养资格;镇社区受理中心分管领导审本镇月度清单与特殊业务。日常审批由对应镇办理。
         </template>
       </p>
 
@@ -58,6 +67,16 @@
               <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
             </el-select>
           </el-form-item>
+          <el-form-item v-if="isTownReview" label="名单月份">
+            <el-date-picker
+              v-model="filterMonth"
+              type="month"
+              value-format="YYYY-MM"
+              format="YYYY年M月"
+              style="width: 140px"
+              @change="applyFilter"
+            />
+          </el-form-item>
           <el-form-item v-if="!isTownReview" label="批次号">
             <el-input
               v-model="filterBatchNo"
@@ -80,7 +99,118 @@
         </el-form>
       </div>
 
-      <div class="page-fill__table">
+      <div v-if="isTownReview" class="approvals-view__paper">
+        <div class="approvals-view__paper-title">{{ paperFormTitle }}</div>
+        <div class="approvals-view__paper-meta">
+          <span class="approvals-view__paper-month">{{ paperMonthText }}</span>
+          <span class="approvals-view__paper-unit">
+            <SensitiveRevealSwitch v-model="revealSensitive" />
+            <span>单位:元</span>
+          </span>
+        </div>
+        <el-table
+          v-loading="loading"
+          :data="pagedList"
+          border
+          stripe
+          size="small"
+          @selection-change="onSelectionChange"
+        >
+          <el-table-column v-if="!isHistoryTab" type="selection" width="36" align="center" />
+          <el-table-column label="序号" width="48" align="center">
+            <template #default="{ $index }">{{ (page - 1) * pageSize + $index + 1 }}</template>
+          </el-table-column>
+          <el-table-column label="姓名" width="80">
+            <template #default="{ row }">
+              <PersonNameCell
+                :name="(row as Approval).personName"
+                :death-warning="(row as Approval).deathWarning"
+                :death-status="(row as Approval).deathStatus"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="身份证号码" width="148">
+            <template #default="{ row }">
+              <SensitiveValue
+                :key="`q-id-${(row as Approval).id}`"
+                :row-key="(row as Approval).id"
+                :value="(row as Approval).idCard"
+                kind="idCard"
+                :revealed="revealSensitive"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="户籍性质(农/非)" width="96" align="center">
+            <template #default="{ row }">{{ paperHousehold((row as Approval).householdType) }}</template>
+          </el-table-column>
+          <el-table-column label="村集体经济合作社" width="120" show-overflow-tooltip>
+            <template #default="{ row }">{{ (row as Approval).village || '' }}</template>
+          </el-table-column>
+          <el-table-column label="享受起始年月" width="108" align="center">
+            <template #default="{ row }">{{ paperStartMonth((row as Approval).startMonth) }}</template>
+          </el-table-column>
+          <el-table-column label="享受标准" width="80" align="center">
+            <template #default="{ row }">{{ paperStandard((row as Approval).standard) }}</template>
+          </el-table-column>
+          <el-table-column label="居保匹配" width="108" show-overflow-tooltip>
+            <template #default="{ row }">
+              <span :class="insuranceFail(row as Approval) ? 'approvals-view__ins-fail' : 'approvals-view__ins-ok'">
+                {{ (row as Approval).insuranceStatus || '未匹配' }}
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="备注" min-width="72">
+            <template #default>{{ '' }}</template>
+          </el-table-column>
+          <el-table-column label="新增时间" width="150" align="center" show-overflow-tooltip>
+            <template #default="{ row }">{{ (row as Approval).addedAt || '' }}</template>
+          </el-table-column>
+          <el-table-column v-if="isHistoryTab" label="审批结果" width="88" align="center">
+            <template #default="{ row }">
+              <StatusTag :status="row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="168" align="center" fixed="right" class-name="approvals-view__ops">
+            <template #default="{ row }">
+              <el-button link type="primary" size="small" @click="openDetail(row as Approval)">
+                详情
+              </el-button>
+              <el-button
+                v-if="row.status === '待审批'"
+                link
+                type="success"
+                size="small"
+                @click="onDecide(row as Approval, true)"
+              >
+                通过
+              </el-button>
+              <el-button
+                v-if="row.status === '待审批'"
+                link
+                type="danger"
+                size="small"
+                @click="onDecide(row as Approval, false)"
+              >
+                驳回
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="approvals-view__paper-sign">
+          <div>
+            <p>经办单位(盖章):(镇经济发展服务中心)</p>
+            <p>负责人签名:</p>
+            <p>日期:</p>
+          </div>
+          <div>
+            <p>审核单位(盖章):(镇社区事务受理服务中心)</p>
+            <p>负责人签名:</p>
+            <p>日期:</p>
+          </div>
+        </div>
+      </div>
+
+      <div v-else class="page-fill__table">
       <el-table
         v-loading="loading"
         :data="pagedList"
@@ -91,7 +221,9 @@
         @selection-change="onSelectionChange"
       >
         <el-table-column v-if="!isHistoryTab" type="selection" width="42" align="center" />
-        <el-table-column prop="bizType" label="业务类型" width="130" />
+        <el-table-column label="业务类型" width="130">
+          <template #default="{ row }">{{ displayBizText((row as Approval).bizType) }}</template>
+        </el-table-column>
         <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
         <el-table-column v-if="!isTownReview" prop="batchNo" label="批次号" width="88" align="center" />
         <el-table-column v-if="!isTownReview || isAdmin" prop="town" label="街镇" width="90" />
@@ -178,23 +310,27 @@
     <!-- 审批详情:含本村人员列表 -->
     <el-dialog
       v-model="detailVisible"
-      title="审批详情"
+      :title="detailDialogTitle"
       :width="isTownReview ? '1180px' : '1120px'"
       destroy-on-close
       @closed="onDetailClosed"
     >
       <el-descriptions v-if="currentRow" :column="2" border size="small">
-        <el-descriptions-item label="业务类型">{{ currentRow.bizType }}</el-descriptions-item>
+        <el-descriptions-item label="业务类型">{{ displayBizText(currentRow.bizType) }}</el-descriptions-item>
         <el-descriptions-item label="审批节点">{{ currentRow.node }}</el-descriptions-item>
         <el-descriptions-item label="标题" :span="2">{{ currentRow.title }}</el-descriptions-item>
         <el-descriptions-item v-if="!isQualifyApproval(currentRow)" label="批次号">{{ currentRow.batchNo }}</el-descriptions-item>
-        <el-descriptions-item v-else label="人员">{{ currentRow.personName || currentRow.title }}</el-descriptions-item>
         <el-descriptions-item label="状态">
           <StatusTag :status="currentRow.status" />
         </el-descriptions-item>
         <el-descriptions-item label="街镇">{{ currentRow.town }}</el-descriptions-item>
         <el-descriptions-item label="村居">{{ currentRow.village || '-' }}</el-descriptions-item>
-        <el-descriptions-item label="申请人">{{ currentRow.applicant }}</el-descriptions-item>
+        <el-descriptions-item
+          v-if="!isQualifyApproval(currentRow) || (currentRow.applicant && currentRow.applicant !== currentRow.personName)"
+          label="申请人"
+        >
+          {{ currentRow.applicant }}
+        </el-descriptions-item>
         <el-descriptions-item label="提交时间">{{ currentRow.createdAt }}</el-descriptions-item>
         <el-descriptions-item v-if="!isSpecialApproval(currentRow) && !isQualifyApproval(currentRow)" label="总发放金额" :span="2">
           <strong>{{ formatAmount(detailBatchAmountSummary.payable) }}</strong>
@@ -222,7 +358,7 @@
           >
             {{ adjustSpecialSummary(currentRow) }}
           </el-descriptions-item>
-          <el-descriptions-item v-if="currentRow.personName" label="人员">
+          <el-descriptions-item v-if="currentRow.personName && !isQualifyApproval(currentRow)" label="人员">
             {{ currentRow.personName }}
           </el-descriptions-item>
           <el-descriptions-item v-if="currentRow.deathDate" label="死亡日期">
@@ -238,7 +374,7 @@
       </el-descriptions>
 
       <p v-if="isQualifyApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
-        退养资格:通过后人员状态为「新增」,等待每月 1 号纳入全区月发;认定无资格进入异常待处理;驳回补材料退回村合作社。
+        退养资格:只审本次新增。通过后人员状态为「新增」,等每月 15 号纳入全区月发;驳回补材料退回村合作社。
       </p>
       <p v-else-if="isSpecialApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
         特殊业务:通过后系统按业务类型回写人员状态(死亡登记通过后改为「死亡丧葬」并停止续发);驳回后退回业务受理补充材料。
@@ -261,7 +397,11 @@
           size="small"
           max-height="240"
         >
-          <el-table-column prop="name" label="姓名" width="90" />
+          <el-table-column label="姓名" width="140">
+            <template #default="{ row }">
+              <PersonNameCell :person="row" />
+            </template>
+          </el-table-column>
           <el-table-column label="身份证" min-width="168">
             <template #default="{ row }">
               <SensitiveValue
@@ -399,27 +539,26 @@
           :summary-method="detailTableSummary"
           :row-class-name="detailPeopleRowClass"
         >
-          <el-table-column prop="name" label="姓名" width="110">
+          <el-table-column prop="name" label="姓名" width="168">
             <template #default="{ row }">
-              {{ row.name }}
-              <el-tag
-                v-if="(row as BatchMemberPerson).changeFlag === 'NEW'"
-                size="small"
-                type="success"
-                effect="plain"
-                style="margin-left: 4px"
-              >
-                新增
-              </el-tag>
-              <el-tag
-                v-else-if="(row as BatchMemberPerson).changeFlag === 'REMOVE'"
-                size="small"
-                type="danger"
-                effect="plain"
-                style="margin-left: 4px"
-              >
-                减少
-              </el-tag>
+              <PersonNameCell :person="row">
+                <el-tag
+                  v-if="(row as BatchMemberPerson).changeFlag === 'NEW'"
+                  size="small"
+                  type="success"
+                  effect="plain"
+                >
+                  新增
+                </el-tag>
+                <el-tag
+                  v-else-if="(row as BatchMemberPerson).changeFlag === 'REMOVE'"
+                  size="small"
+                  type="danger"
+                  effect="plain"
+                >
+                  减少
+                </el-tag>
+              </PersonNameCell>
             </template>
           </el-table-column>
           <el-table-column label="身份证" min-width="168">
@@ -477,7 +616,7 @@
                 :type="qualifyTagType(row.retireQualifyType)"
                 effect="plain"
               >
-                {{ row.retireQualifyType }}
+                {{ retireQualifyLabel(row.retireQualifyType) }}
               </el-tag>
               <span v-else class="muted">待核验</span>
             </template>
@@ -503,18 +642,8 @@
                 详情
               </el-button>
               <template v-if="isTownReview">
-                <el-button
-                  v-if="canReviewQualify(row as Person)"
-                  link
-                  type="primary"
-                  size="small"
-                  :loading="reviewingId === row.id"
-                  @click="openQualifyDialog(row as Person)"
-                >
-                  核验资格
-                </el-button>
                 <span
-                  v-else-if="row.retireQualifyType === '无资格' || row.status === '无资格'"
+                  v-if="row.retireQualifyType === '无资格' || row.status === '无资格'"
                   class="muted"
                 >
                   无资格
@@ -523,7 +652,7 @@
                   v-else-if="row.retireQualifyType"
                   class="muted"
                 >
-                  已核验
+                  村级已填
                 </span>
               </template>
             </template>
@@ -580,12 +709,6 @@
         {{ passTip }}
       </p>
       <el-form label-width="90px" size="small">
-        <el-form-item v-if="isQualifyPassTarget" label="资格认定" required>
-          <el-radio-group v-model="passQualifyType">
-            <el-radio value="有土地退养">有土地退养</el-radio>
-            <el-radio value="无土地有资格">无土地有资格</el-radio>
-          </el-radio-group>
-        </el-form-item>
         <el-form-item label="审批意见">
           <el-input
             v-model="passOpinion"
@@ -687,7 +810,7 @@
     <!-- 镇经发:核验退养资格 -->
     <el-dialog v-model="qualifyVisible" title="核验退养资格" width="520px" destroy-on-close>
       <p class="approvals-view__qualify-tip">
-        请确认该人员是否具备土地退养资格。选择「有土地退养」或「无土地有资格」后通过,人员变为「新增」,等每月 1 号纳入全区月发。认定「无资格」则进入异常待处理。
+        请确认该人员是否具备土地退养资格。选择「有土地退养」或「无土地有资格」后通过,人员变为「新增」,等每月 15 号纳入全区月发。认定「无资格」则进入异常待处理。
       </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>
@@ -708,7 +831,7 @@
         </el-form-item>
       </el-form>
       <p v-if="qualifyType === '无资格'" class="approvals-view__qualify-warn">
-        认定无资格后,人员进入异常待处理,不再纳入每月 1 号全区月发。
+        认定无资格后,人员进入异常待处理,不再纳入每月 15 号全区月发。
       </p>
       <template #footer>
         <el-button :disabled="reviewingId !== null" @click="qualifyVisible = false">取消</el-button>
@@ -738,6 +861,7 @@
 
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import type { UploadFile, UploadUserFile } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
@@ -749,6 +873,7 @@ import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescrip
 import {
   decideApproval,
   decideApprovals,
+  exportPersonnelNewMonth,
   fetchApprovals,
   fetchBatchMemberPeoplePage,
   fetchPersonDetail,
@@ -770,14 +895,18 @@ import {
 } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import { formatAmount } from '@/utils/mask'
+import { displayBizText, retireQualifyLabel } from '@/utils/statusLabels'
 import type { Approval, BatchMemberPerson, Person } from '@/types/biz'
 import SensitiveValue from '@/components/common/SensitiveValue.vue'
 import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
 
 const userStore = useUserStore()
+const route = useRoute()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
 /** 审批详情证件/卡号脱敏开关 */
 const revealSensitive = ref(false)
+const newMonthExporting = ref(false)
 const {
   filterTown,
   filterVillage,
@@ -792,6 +921,7 @@ const activeTab = ref('pending')
 const isHistoryTab = computed(() => activeTab.value === 'history')
 const filterBatchNo = ref('')
 const filterStatus = ref('')
+const filterMonth = ref(userStore.currentMonth || '')
 const deciding = ref(false)
 const selectedIds = ref<string[]>([])
 const detailVisible = ref(false)
@@ -980,6 +1110,7 @@ function applyFilter() {
 function resetFilter() {
   filterBatchNo.value = ''
   filterStatus.value = ''
+  filterMonth.value = userStore.currentMonth || ''
   if (isAdmin.value) {
     resetTownVillage()
   } else {
@@ -988,6 +1119,23 @@ function resetFilter() {
   void loadApprovals()
 }
 
+async function onExportNewMonth() {
+  if (guardBusy(newMonthExporting)) return
+  newMonthExporting.value = true
+  try {
+    await exportPersonnelNewMonth({
+      town: filterTown.value || userStore.roleInfo?.town || undefined,
+      village: filterVillage.value || undefined,
+      payMonth: (filterMonth.value || userStore.currentMonth || '').replace('-', ''),
+    })
+    ElMessage.success('已导出新增人员名单')
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '导出失败')
+  } finally {
+    newMonthExporting.value = false
+  }
+}
+
 async function syncScopeTown() {
   const town = userStore.roleInfo?.town
   if (!isAdmin.value && town) {
@@ -1038,19 +1186,45 @@ function resetDetailSearch() {
 /** 按角色限定审批节点 */
 const approvalNode = computed(() => {
   if (userStore.currentRole === 'town') return '镇经发中心初审'
-  if (userStore.currentRole === 'leader') return '分管领导线上审批'
+  if (userStore.currentRole === 'leader') return '镇社区受理中心分管领导线上审批'
   return undefined
 })
 
 const isTownReview = computed(() => userStore.currentRole === 'town')
 const isLeaderReview = computed(() => userStore.currentRole === 'leader')
 
+/** 刚审完的单:本会话按角色记住,离开再进仍过滤;接口若因缓存仍返回待审,列表先剔除 */
+function decidedStorageKey(role?: string) {
+  return `sj_tdtybz_decided_approvals_${role || userStore.currentRole || 'default'}`
+}
+
+function readDecidedIds(role?: string): Set<string> {
+  try {
+    const raw = sessionStorage.getItem(decidedStorageKey(role))
+    const arr = raw ? JSON.parse(raw) : []
+    return new Set(Array.isArray(arr) ? arr.filter((id) => typeof id === 'string' && id) : [])
+  } catch {
+    return new Set()
+  }
+}
+
+function persistDecidedIds(ids: Set<string>, role?: string) {
+  try {
+    sessionStorage.setItem(decidedStorageKey(role), JSON.stringify([...ids]))
+  } catch {
+    /* ignore quota */
+  }
+}
+
+const decidedIds = ref<Set<string>>(readDecidedIds())
+
 const {
   page,
   pageSize,
   total,
   list: pagedList,
   loading,
+  load: reloadApprovals,
   resetAndLoad: loadApprovals,
 } = useServerPagination<Approval>(async (pageNum, pageSize) => {
   selectedIds.value = []
@@ -1062,22 +1236,89 @@ const {
     town: isTownReview.value || isLeaderReview.value ? role?.town : filterTown.value || undefined,
     village: filterVillage.value || undefined,
     batchNo: filterBatchNo.value.trim() || undefined,
+    enjoyStartMonth: isTownReview.value
+      ? (filterMonth.value || userStore.currentMonth || '').replace('-', '')
+      : undefined,
     status: isHistoryTab.value ? filterStatus.value || undefined : undefined,
     pageNum,
     pageSize,
   })
-  void refreshUnresolvedInsuranceForList(page.list || [])
-  return page
+  const hidden = decidedIds.value
+  const raw = page.list || []
+  const list = isPending && hidden.size ? raw.filter((r) => !hidden.has(r.id)) : raw
+  void refreshUnresolvedInsuranceForList(list)
+  return {
+    ...page,
+    list,
+    total: Math.max(0, Number(page.total || 0) - (raw.length - list.length)),
+  }
 })
 const list = pagedList
 
+function markDecided(ids: string[]) {
+  if (!ids.length) return
+  const next = new Set(decidedIds.value)
+  for (const id of ids) {
+    if (id) next.add(id)
+  }
+  decidedIds.value = next
+  persistDecidedIds(next)
+  pagedList.value = pagedList.value.filter((r) => !next.has(r.id))
+  total.value = Math.max(0, total.value - ids.length)
+}
+
+const paperTownName = computed(() => filterTown.value || userStore.roleInfo?.town || '')
+const paperFormTitle = computed(
+  () => `松江区${paperTownName.value}老年农民享受土地退养补助金新增名单`,
+)
+const paperMonthText = computed(() => {
+  const m = String(filterMonth.value || userStore.currentMonth || '').replace('-', '')
+  if (m.length >= 6) return `${m.slice(0, 4)}年${Number(m.slice(4, 6))}月`
+  return ''
+})
+
+function paperHousehold(v?: string) {
+  const s = String(v || '').trim()
+  if (!s) return ''
+  if (s.includes('非')) return '非农'
+  if (s.includes('农')) return '农业'
+  return s
+}
+
+function paperStartMonth(v?: string) {
+  const s = String(v || '').replace(/-/g, '')
+  if (s.length >= 6) return `${s.slice(0, 4)}年${Number(s.slice(4, 6))}月`
+  return v || ''
+}
+
+function insuranceFail(row: Approval) {
+  const t = String(row.insuranceStatus || '')
+  return t !== '' && t !== '未匹配' && t !== '匹配通过' && t !== '人工确认匹配成功'
+}
+
+function paperStandard(n?: number | null) {
+  if (n == null || Number.isNaN(Number(n))) return ''
+  const v = Number(n)
+  return Number.isInteger(v) ? String(v) : String(v)
+}
+
 const pageTitle = computed(() => {
   if (isTownReview.value) return '退养资格审批'
-  if (isLeaderReview.value) return '审批管理(分管领导线上审批)'
+  if (isLeaderReview.value) return '审批管理(镇社区受理中心分管领导)'
   if (isAdmin.value) return '审批管理(全区 · 按镇+村)'
   return '审批管理'
 })
 
+const detailDialogTitle = computed(() => {
+  const row = currentRow.value
+  if (!row) return '审批详情'
+  if (isQualifyApproval(row)) return '退养资格审批'
+  if (isSpecialApproval(row)) return '特殊业务审批'
+  if (isLeaderReview.value) return '月补贴清单审批'
+  if (isTownReview.value) return '退养资格审批'
+  return '审批详情'
+})
+
 /** 当前发放周期对应批次号 */
 const currentBatchNo = computed(() => userStore.currentMonth.replace('-', ''))
 
@@ -1393,7 +1634,7 @@ async function doDecide(
 ) {
   const qualifyType = isQualifyApproval(row)
     ? pass
-      ? passQualifyType.value
+      ? undefined
       : rejectQualifyMode.value === 'ineligible'
         ? '无资格'
         : undefined
@@ -1432,8 +1673,9 @@ async function doDecide(
     )
   }
   detailVisible.value = false
+  markDecided([row.id])
   clearGetCache()
-  await loadApprovals()
+  await reloadApprovals()
 }
 
 async function townPassBlockReason(row: Approval, people?: Person[]): Promise<string | null> {
@@ -1507,7 +1749,7 @@ async function onDecide(row: Approval, pass: boolean) {
     passFileList.value = []
     passRawFiles.value = []
     if (isQualifyApproval(row) || isTownReview.value || row.node.includes('镇经发')) {
-      passTip.value = `确认通过「${row.title}」?村新提交的人员通过后变为「新增」;已在正常发放的人员只补登记退养资格。`
+      passTip.value = `确认通过「${row.title}」?仅本次新增人员,资格以村级勾选为准,通过后变为「新增」。`
     } else if (isSpecialApproval(row)) {
       passTip.value = `确认通过「${row.title}」?特殊业务通过后将按类型回写人员状态(死亡登记通过后变为「死亡丧葬」)。`
     } else {
@@ -1539,13 +1781,16 @@ async function confirmPass() {
         pass: true,
         opinion,
         fileUrls,
-        retireQualifyType: isTownReview.value ? passQualifyType.value : undefined,
+        retireQualifyType: undefined,
       })
       ElMessage.success(isTownReview.value ? '批量资格审批通过' : '批量通过成功')
+      const ids = [...selectedIds.value]
       selectedIds.value = []
       passVisible.value = false
       detailVisible.value = false
-      await loadApprovals()
+      markDecided(ids)
+      clearGetCache()
+      await reloadApprovals()
       return
     }
     if (passTarget.value) {
@@ -1583,10 +1828,13 @@ async function confirmReject() {
             : undefined,
       })
       ElMessage.success('批量驳回成功')
+      const ids = [...selectedIds.value]
       selectedIds.value = []
       rejectVisible.value = false
       detailVisible.value = false
-      await loadApprovals()
+      markDecided(ids)
+      clearGetCache()
+      await reloadApprovals()
       return
     }
     if (rejectTarget.value) {
@@ -1682,11 +1930,41 @@ watch([detailPeoplePage, detailPeoplePageSize], () => {
   void reloadDetailPeople()
 })
 
+/** 从审批超时告警带批次号进来:筛出这一单并打开审批详情。 */
+async function openBatchFromAlert() {
+  const batchNo = String(route.query.batchNo || '').trim()
+  if (!batchNo || isTownReview.value) return
+  filterBatchNo.value = batchNo
+  activeTab.value = 'pending'
+  await loadApprovals()
+  const hit = pagedList.value.find((row) => {
+    const no = String(row.batchNo || '')
+    return no === batchNo || no.replace(/-/g, '') === batchNo.replace(/-/g, '')
+  })
+  if (hit && hit.status === '待审批') {
+    await openDetail(hit)
+    return
+  }
+  ElMessage.warning(`批次 ${batchNo} 不在待审批列表,可能已审完,或不是当前角色能审的单`)
+}
+
 onMounted(async () => {
   await syncScopeTown()
-  await loadApprovals()
+  if (String(route.query.batchNo || '').trim() && !isTownReview.value) {
+    await openBatchFromAlert()
+  } else {
+    await loadApprovals()
+  }
 })
 
+watch(
+  () => route.query.batchNo,
+  (batchNo) => {
+    if (!String(batchNo || '').trim() || isTownReview.value) return
+    void openBatchFromAlert()
+  },
+)
+
 watch(
   () => [userStore.currentRole, userStore.currentMonth],
   async () => {
@@ -1697,6 +1975,7 @@ watch(
       filterVillage.value = ''
       await syncScopeTown()
     }
+    decidedIds.value = readDecidedIds()
     await loadApprovals()
   },
 )
@@ -1817,4 +2096,79 @@ watch(
   color: @text-secondary;
   font-size: 12px;
 }
+
+.approvals-view__paper {
+  min-height: 0;
+  overflow-x: auto;
+}
+
+.approvals-view__paper-title {
+  text-align: center;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  margin: 4px 0 8px;
+}
+
+.approvals-view__paper-meta {
+  position: relative;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  min-height: 28px;
+  padding: 0 4px 10px;
+  font-size: 13px;
+}
+
+.approvals-view__paper-month {
+  position: absolute;
+  left: 0;
+  right: 0;
+  text-align: center;
+  pointer-events: none;
+}
+
+.approvals-view__paper-unit {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: #fff;
+}
+
+.approvals-view__paper-sign {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+  margin-top: 28px;
+  padding: 0 8px 12px;
+  font-size: 13px;
+  line-height: 2.2;
+}
+
+.approvals-view__paper-sign p {
+  margin: 0;
+}
+
+.approvals-view__ins-ok {
+  color: #1a7f37;
+}
+
+.approvals-view__ins-fail {
+  color: #c45656;
+}
+
+:deep(.approvals-view__ops) .cell {
+  white-space: nowrap;
+}
+
+:deep(.approvals-view__ops) .el-button {
+  margin-left: 0;
+  padding: 0 2px;
+}
+
+:deep(.approvals-view__ops) .el-button + .el-button {
+  margin-left: 6px;
+}
 </style>

+ 83 - 15
src/views/base/BaseInfoView.vue

@@ -32,11 +32,11 @@
         <template #title>
           <template v-if="isAdmin">
             运维查看人员底数,当前范围:<em>{{ villageScopeLabel }}</em>
-            。可不选查看全区,选街镇看本镇,再选村居看本村。单人录入/导入仍须先选街镇与村居。资格通过为「新增」,每月 1 号纳入全区月发。
+            。可不选查看全区,选街镇看本镇,再选村居看本村。单人录入/导入仍须先选街镇与村居。资格通过为「新增」,每月 15 号纳入全区月发。
           </template>
           <template v-else>
             当前村:<em>{{ villageScopeLabel }}</em>
-            。录入或导入后为「待提交资格」,材料齐全后点「提交资格」送镇经发;通过后为「新增」,等每月 1 号纳入全区月发。本页不展示批次。
+            。录入或导入后为「待提交资格」,材料齐全后点「提交资格」送镇经发;通过后为「新增」,等每月 15 号纳入全区月发。本页不展示批次。
           </template>
         </template>
       </el-alert>
@@ -108,6 +108,7 @@
             placeholder="全部"
             style="width: 130px"
             class="is-editable"
+            popper-class="base-info-view__status-popper"
           >
             <el-option
               v-for="s in rosterStatusOptions"
@@ -165,7 +166,13 @@
         <el-form-item label=" ">
           <el-button type="primary" @click="applyRosterFilter">查询</el-button>
           <el-button @click="resetRosterFilter">重置</el-button>
-          <SensitiveRevealSwitch v-model="revealSensitive" class="base-info-view__reveal" />
+          <el-button :loading="rosterExporting" @click="onExportRoster">列表导出</el-button>
+          <SensitiveRevealSwitch
+            v-model="revealSensitive"
+            class="base-info-view__reveal"
+            :inactive-text="isVillage ? '显示身份证' : '显示身份证和银行卡号'"
+            :active-text="isVillage ? '已显示身份证' : '已显示证件卡号'"
+          />
         </el-form-item>
       </el-form>
 
@@ -180,7 +187,11 @@
           @selection-change="onPeopleSelectionChange"
         >
           <el-table-column type="selection" width="42" :selectable="canSubmitQualify" />
-          <el-table-column prop="name" label="姓名" width="80" />
+          <el-table-column label="姓名" width="140">
+            <template #default="{ row }">
+              <PersonNameCell :person="row" />
+            </template>
+          </el-table-column>
           <el-table-column label="身份证" min-width="168">
             <template #default="{ row }">
               <SensitiveValue
@@ -197,7 +208,10 @@
           </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="160">
+          <el-table-column label="户籍性质" width="88" align="center">
+            <template #default="{ row }">{{ householdTypeLabel(row.householdType) }}</template>
+          </el-table-column>
+          <el-table-column v-if="!isVillage" label="银行卡" min-width="160">
             <template #default="{ row }">
               <SensitiveValue
                 :key="`bank-${row.id}`"
@@ -208,7 +222,7 @@
               />
             </template>
           </el-table-column>
-          <el-table-column label="开户行" min-width="100" show-overflow-tooltip>
+          <el-table-column v-if="!isVillage" label="开户行" min-width="100" show-overflow-tooltip>
             <template #default="{ row }">{{ row.bankName || '—' }}</template>
           </el-table-column>
           <el-table-column label="月补标准(街镇)" min-width="130" align="right">
@@ -558,7 +572,7 @@
           请确认已选择街镇与村居({{ villageScopeLabel }})。姓名、身份证号、手机号、银行卡号为必填;月补标准由街镇标准自动带出,无需手填。灰色项为系统只读。
         </template>
         <template v-else>
-          仅可录入本村({{ villageScopeLabel }})人员。保存后为「待提交资格」,材料齐全后再提交镇经发审批。姓名、身份证号、手机号、银行卡号为必填;社保登记码建议一并填写。
+          只填姓名和身份证。退养资格默认为「无土地有资格」,区划已按当前村({{ villageScopeLabel }})自动带出。保存后为「待提交资格」,其他居民信息于次日由居保匹配接口补全。
         </template>
       </p>
       <el-form
@@ -575,7 +589,7 @@
           :village-options="villageOptions"
           :town-disabled="true"
           :village-disabled="true"
-          :required-keys="['name', 'idCard', 'phoneNumber', 'bankCard']"
+          :required-keys="isAdmin ? ['name', 'idCard', 'phoneNumber', 'bankCard'] : ['name', 'idCard']"
           @update:model="onPersonFormUpdate"
         />
       </el-form>
@@ -600,6 +614,7 @@ import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
   downloadPersonnelImportTemplate,
   exportPersonnelImportFailRows,
+  exportPersonnelRoster,
   fetchAlertDetail,
   fetchPeoplePage,
   fetchPersonDetail,
@@ -621,7 +636,7 @@ import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
-import { retireQualifyLabel } from '@/utils/statusLabels'
+import { householdTypeLabel, retireQualifyLabel } from '@/utils/statusLabels'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import {
   explainIdCard,
@@ -636,6 +651,7 @@ import type {
 } from '@/types/biz'
 import SensitiveValue from '@/components/common/SensitiveValue.vue'
 import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
 
 const PEOPLE_PAGE_SIZE = 20
 const peoplePageSizes = [10, 20, 50]
@@ -644,6 +660,7 @@ const revealSensitive = ref(false)
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
+const isVillage = computed(() => userStore.currentRole === 'village')
 
 const {
   filterTown,
@@ -676,11 +693,11 @@ const rosterStatusOptions = [
   { label: '待资格审批', value: 'PENDING_TOWN_APPROVE' },
   { label: '新增', value: 'NEW' },
   { label: '正常发放', value: 'NORMAL' },
+  { label: '死亡丧葬', value: 'FUNERAL' },
   { label: '已退出', value: 'EXITED' },
-  { label: '待居保匹配', value: 'PENDING_INSURANCE_MATCH' },
   { label: '暂停', value: 'PAUSED' },
   { label: '停止', value: 'STOPPED' },
-  { label: '死亡丧葬', value: 'FUNERAL' },
+  { label: '待居保匹配', value: 'PENDING_INSURANCE_MATCH' },
   { label: '异常待处理', value: 'ABNORMAL' },
   { label: '已归档', value: 'ARCHIVED' },
 ]
@@ -816,6 +833,37 @@ function onPersonHistoryClosed() {
   personHistoryRows.value = []
 }
 
+const rosterExporting = ref(false)
+
+async function onExportRoster() {
+  if (guardBusy(rosterExporting)) return
+  const role = userStore.roleInfo
+  if (!isAdmin.value && !role?.village) {
+    ElMessage.warning('当前账号未绑定村居,无法导出')
+    return
+  }
+  rosterExporting.value = true
+  try {
+    await exportPersonnelRoster({
+      town: isAdmin.value ? filterTown.value || undefined : role?.town,
+      village: isAdmin.value ? filterVillage.value || undefined : role?.village,
+      name: rosterFilter.name.trim() || undefined,
+      idCard: rosterFilter.idCard.trim().replace(/\s/g, '') || undefined,
+      phone: rosterFilter.phone.trim() || undefined,
+      bizStatus: rosterFilter.bizStatus || undefined,
+      insuranceMatchResult: rosterFilter.insuranceMatchResult || undefined,
+      retireQualifyType: rosterFilter.retireQualifyType || undefined,
+      source: rosterFilter.source.trim() || undefined,
+      omitBank: isVillage.value || undefined,
+    })
+    ElMessage.success('已导出人员列表')
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '导出失败')
+  } finally {
+    rosterExporting.value = false
+  }
+}
+
 function applyRosterFilter() {
   void peoplePager.resetAndLoad()
 }
@@ -1024,7 +1072,7 @@ function onPersonFormUpdate(v: Person) {
   personForm.value = v
 }
 
-const personFormRules: FormRules = {
+const personNameIdRules: FormRules = {
   name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
   idCard: [
     { required: true, message: '请输入身份证号', trigger: 'blur' },
@@ -1037,6 +1085,8 @@ const personFormRules: FormRules = {
       trigger: 'blur',
     },
   ],
+}
+const personExtraRules: FormRules = {
   phoneNumber: [
     { required: true, message: '请输入手机号', trigger: 'blur' },
     {
@@ -1090,6 +1140,9 @@ const personFormRules: FormRules = {
     },
   ],
 }
+const personFormRules = computed<FormRules>(() =>
+  isAdmin.value ? { ...personNameIdRules, ...personExtraRules } : personNameIdRules,
+)
 
 function canSubmitQualify(row: Person) {
   if (row.retireQualifyType === '无资格' || row.status === '无资格') return false
@@ -1195,7 +1248,7 @@ function openPersonDialog() {
   personForm.value = emptyPersonForm({
     town,
     village,
-    source: '村级录入',
+    source: '人工新增',
     payeeName: '',
   })
   personDialogVisible.value = true
@@ -1232,9 +1285,15 @@ async function onSavePerson() {
       heirBankCard: (f.heirBankCard || '').trim(),
       heirPhone: (f.heirPhone || '').trim(),
       remark: (f.remark || '').trim(),
-      source: f.source || '村级录入',
+      source: isAdmin.value ? (f.source || '村级录入') : '人工新增',
     })
-    ElMessage.success(saved?.name ? `「${saved.name}」保存成功,状态为待提交资格` : '人员保存成功')
+    ElMessage.success(
+      saved?.name
+        ? isAdmin.value
+          ? `「${saved.name}」保存成功,状态为待提交资格`
+          : `「${saved.name}」已保存,退养资格为无土地有资格,其他居民信息于次日补全`
+        : '人员保存成功',
+    )
     personDialogVisible.value = false
     rosterFilter.bizStatus = 'DRAFT'
     rosterFilter.source = ''
@@ -1257,6 +1316,15 @@ watch(
 )
 </script>
 
+<style lang="less">
+.base-info-view__status-popper {
+  .el-select-dropdown__wrap,
+  .el-scrollbar__wrap {
+    max-height: 420px !important;
+  }
+}
+</style>
+
 <style scoped lang="less">
 .base-info-view {
   :deep(.page-alert) {

+ 419 - 381
src/views/dashboard/DashboardView.vue

@@ -7,8 +7,13 @@
     }"
     v-loading="loading"
   >
-    <!-- 5.1.1.1 核心数据指标区:总人数 / 本月新增 / 本月减少 / 本月发放金额 -->
+    <!-- 核心指标:在册总人数 ≠ 本月发放人数 -->
     <StatCards :items="statItems" />
+    <p class="dashboard-stat-note">
+      <strong>在册总人数</strong>是当前角色范围内的人员档案底数(含暂停、死亡等仍在库人员),
+      <strong>不是</strong>本月实际发放人数。
+      本月发放人数与金额按当前发放周期的月补清单口径统计,见下方对照表。
+    </p>
 
     <!-- 5.1.1.1 近期告警滚动;点击跳转告警页 -->
     <section class="alert-ticker" aria-label="近期告警">
@@ -103,7 +108,7 @@
       </div>
     </section>
 
-    <!-- 可研 5.1.1.2:各角色待办(点击跳转 + 已读) -->
+    <!-- 待办单独一行,放在状态占比/发放表之上 -->
     <PagePanel
       v-if="(data?.todos?.length ?? 0) > 0"
       title="待办事项"
@@ -160,174 +165,72 @@
       />
     </PagePanel>
 
-    <!-- 村合作社:左图右提示,占满可视区域避免大块留白 -->
-    <div v-if="isVillage" class="village-workspace">
-      <div class="village-workspace__charts">
-        <PagePanel title="本村近半年发放人数">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="villagePeopleBarOption" height="100%" />
-          </div>
-        </PagePanel>
-        <PagePanel title="本村近半年发放金额">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="villageAmountBarOption" height="100%" />
-          </div>
-        </PagePanel>
-      </div>
-
-      <PagePanel title="村级业务操作提示" class="village-workspace__tips-panel">
-        <div class="village-tips">
-          <div
-            v-for="(tip, idx) in data?.villageTips ?? []"
-            :key="idx"
-            class="village-tips__item"
-          >
-            <div class="village-tips__rail">
-              <span class="village-tips__index">{{ idx + 1 }}</span>
-              <span
-                v-if="idx < (data?.villageTips?.length ?? 0) - 1"
-                class="village-tips__line"
-              />
-            </div>
-            <div class="village-tips__card">
-              <div class="village-tips__title">{{ tip.title }}</div>
-              <p class="village-tips__desc">{{ tip.desc }}</p>
-            </div>
-          </div>
-        </div>
-      </PagePanel>
-    </div>
-
-    <!-- 镇经发中心:左图右提示,布局对齐村合作社工作台 -->
-    <div v-else-if="isTown" class="village-workspace">
-      <div class="village-workspace__charts">
-        <PagePanel title="本镇各村发放金额">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillageAmountOption" height="100%" />
-          </div>
-        </PagePanel>
-        <PagePanel title="本镇各村发放人数">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillagePeopleOption" height="100%" />
-          </div>
-        </PagePanel>
-      </div>
-
-      <PagePanel title="资格审批要点" class="village-workspace__tips-panel">
-        <div class="village-tips">
-          <div
-            v-for="(tip, idx) in data?.townReview?.tips ?? []"
-            :key="idx"
-            class="village-tips__item"
+    <!-- 村 / 镇:发放表与状态饼图并排;待办已在上方单独一行 -->
+    <div v-if="isTownLevelBoard" class="village-workspace">
+      <div class="village-workspace__main" :class="{ 'has-pie': sidePieItems.length > 0 }">
+        <PieMixPanel
+          v-if="sidePieItems.length > 0"
+          class="village-workspace__pie"
+          :title="sidePieTitle"
+          :items="sidePieItems"
+        />
+
+        <PagePanel :title="payTableTitle" class="village-workspace__table-panel village-workspace__pay">
+          <el-table
+            v-loading="payTableLoading"
+            :data="payTableRows"
+            border
+            stripe
+            size="small"
+            max-height="300"
           >
-            <div class="village-tips__rail">
-              <span class="village-tips__index">{{ idx + 1 }}</span>
-              <span
-                v-if="idx < (data?.townReview?.tips?.length ?? 0) - 1"
-                class="village-tips__line"
-              />
-            </div>
-            <div class="village-tips__card">
-              <div class="village-tips__title">{{ tip.title }}</div>
-              <p class="village-tips__desc">{{ tip.desc }}</p>
-            </div>
-          </div>
-        </div>
-      </PagePanel>
-    </div>
-
-    <!-- 镇社区事务受理中心:布局对齐村合作社 / 镇经发 -->
-    <div v-else-if="isSocial" class="village-workspace">
-      <div class="village-workspace__charts">
-        <PagePanel title="本月本镇各村发放金额">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillageAmountOption" height="100%" />
-          </div>
-        </PagePanel>
-        <PagePanel title="本月本镇各村发放人数">
-          <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillagePeopleOption" height="100%" />
-          </div>
+            <el-table-column prop="name" :label="payTableNameLabel" min-width="110" />
+            <el-table-column label="发放人数" width="100" align="right">
+              <template #default="{ row }">{{ formatHeadcount(row.people) }}</template>
+            </el-table-column>
+            <el-table-column label="发放金额(元)" min-width="120" align="right">
+              <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
+            </el-table-column>
+          </el-table>
         </PagePanel>
       </div>
 
-      <PagePanel title="业务办理要点" class="village-workspace__tips-panel">
-        <div class="village-tips">
-          <div
-            v-for="(tip, idx) in data?.socialWorkbench?.tips ?? []"
-            :key="idx"
-            class="village-tips__item"
-          >
-            <div class="village-tips__rail">
-              <span class="village-tips__index">{{ idx + 1 }}</span>
-              <span
-                v-if="idx < (data?.socialWorkbench?.tips?.length ?? 0) - 1"
-                class="village-tips__line"
-              />
-            </div>
-            <div class="village-tips__card">
-              <div class="village-tips__title">{{ tip.title }}</div>
-              <p class="village-tips__desc">{{ tip.desc }}</p>
-            </div>
-          </div>
-        </div>
-      </PagePanel>
-    </div>
-
-    <!-- 分管领导:审本镇月度清单与特殊业务 -->
-    <div v-else-if="isLeader" class="village-workspace">
-      <div class="village-workspace__charts">
-        <PagePanel title="本月本镇各村发放金额">
+      <div class="village-workspace__trend">
+        <PagePanel title="月度发放人数与金额">
           <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillageAmountOption" height="100%" />
+            <ChartBox :option="townMonthPayCombineOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel title="本月本镇各村发放人数">
+        <PagePanel title="年度发放人数与金额">
           <div class="village-workspace__chart-host">
-            <ChartBox :option="townVillagePeopleOption" height="100%" />
+            <ChartBox :option="townYearPayCombineOption" height="100%" />
           </div>
         </PagePanel>
       </div>
 
-      <PagePanel title="审批要点" class="village-workspace__tips-panel">
-        <div class="village-tips">
-          <div
-            v-for="(tip, idx) in data?.leaderWorkbench?.tips ?? []"
-            :key="idx"
-            class="village-tips__item"
-          >
-            <div class="village-tips__rail">
-              <span class="village-tips__index">{{ idx + 1 }}</span>
-              <span
-                v-if="idx < (data?.leaderWorkbench?.tips?.length ?? 0) - 1"
-                class="village-tips__line"
-              />
-            </div>
-            <div class="village-tips__card">
-              <div class="village-tips__title">{{ tip.title }}</div>
-              <p class="village-tips__desc">{{ tip.desc }}</p>
-            </div>
-          </div>
-        </div>
-      </PagePanel>
+      <WorkbenchTipsPanel
+        :title="workspaceTipsTitle"
+        :tips="workspaceTips"
+        :reset-key="tipsResetKey"
+      />
     </div>
 
     <!-- 区农委 / 区人社局:全区各镇各村监测(无流程待办,图表丰富) -->
     <div v-else-if="isDistrictPolicy" class="district-workspace">
       <div class="district-workspace__charts">
         <!-- 农委侧重资格;人社侧重金额;共用人数图 -->
-        <PagePanel :title="isAgri ? '各街镇有土地退养人数' : '各街镇发放金额'">
+        <PagePanel :title="isAgri ? '各街镇有土地退养人数' : '各街镇发放人数与金额'">
           <div class="district-workspace__chart-host">
             <ChartBox
-              :option="isAgri ? districtWithLandOption : districtTownAmountOption"
+              :option="isAgri ? districtWithLandOption : districtTownPayCombineOption"
               height="100%"
             />
           </div>
         </PagePanel>
-        <PagePanel :title="isAgri ? '各街镇无土地有资格人数' : '各街镇补助人数'">
+        <PagePanel :title="isAgri ? '各街镇无土地有资格人数' : '近半年发放人数与金额'">
           <div class="district-workspace__chart-host">
             <ChartBox
-              :option="isAgri ? districtWithoutLandOption : districtTownPeopleOption"
+              :option="isAgri ? districtWithoutLandOption : districtHalfYearPayCombineOption"
               height="100%"
             />
           </div>
@@ -382,20 +285,27 @@
           />
           <el-table-column
             prop="peopleCount"
-            label="人数"
-            min-width="80"
+            label="发放人数"
+            min-width="88"
             align="center"
             header-align="center"
           />
           <el-table-column
-            v-if="isHr"
-            label="预计金额(元)"
+            label="发放金额(元)"
             min-width="120"
             align="right"
             header-align="center"
           >
             <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
           </el-table-column>
+          <el-table-column
+            label="人均(元)"
+            min-width="88"
+            align="right"
+            header-align="center"
+          >
+            <template #default="{ row }">{{ formatPerCapita(row.amount, row.peopleCount) }}</template>
+          </el-table-column>
           <el-table-column
             prop="withLandCount"
             label="有土地退养"
@@ -418,36 +328,14 @@
             align="center"
             header-align="center"
           />
-          <el-table-column
-            v-if="isAgri"
-            label="预计金额(元)"
-            min-width="120"
-            align="right"
-            header-align="center"
-          >
-            <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
-          </el-table-column>
         </el-table>
       </PagePanel>
 
-      <PagePanel :title="districtTipsTitle">
-        <div class="village-tips">
-          <div
-            v-for="(tip, idx) in districtTips"
-            :key="idx"
-            class="village-tips__item"
-          >
-            <div class="village-tips__rail">
-              <span class="village-tips__index">{{ idx + 1 }}</span>
-              <span v-if="idx < districtTips.length - 1" class="village-tips__line" />
-            </div>
-            <div class="village-tips__card">
-              <div class="village-tips__title">{{ tip.title }}</div>
-              <p class="village-tips__desc">{{ tip.desc }}</p>
-            </div>
-          </div>
-        </div>
-      </PagePanel>
+      <WorkbenchTipsPanel
+        :title="districtTipsTitle"
+        :tips="districtTips"
+        :reset-key="tipsResetKey"
+      />
     </div>
 
     <!-- 其他角色:图表 + 待办告警 -->
@@ -504,9 +392,11 @@ import StatCards from '@/components/common/StatCards.vue'
 import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import ChartBox from '@/components/common/ChartBox.vue'
+import PieMixPanel from '@/components/common/PieMixPanel.vue'
+import WorkbenchTipsPanel from '@/components/common/WorkbenchTipsPanel.vue'
 import { useClientPagination } from '@/composables/useClientPagination'
 import StatusTag from '@/components/common/StatusTag.vue'
-import { fetchDashboard, markWorkbenchTodoRead } from '@/api/biz'
+import { fetchDashboard, fetchTownVillagePayTable, markWorkbenchTodoRead } from '@/api/biz'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
 import {
@@ -531,6 +421,7 @@ const userStore = useUserStore()
 /** shallowRef:整包替换工作台数据,避免深层响应拖慢图表重算 */
 const data = shallowRef<DashboardData | null>(null)
 const loading = ref(false)
+const payTableLoading = ref(false)
 let loadSeq = 0
 let loadTimer: ReturnType<typeof setTimeout> | null = null
 
@@ -543,7 +434,7 @@ const isTown = computed(() => userStore.roleInfo?.key === 'town')
 /** 是否镇社区事务受理中心视角 */
 const isSocial = computed(() => userStore.roleInfo?.key === 'social')
 
-/** 是否分管领导视角 */
+/** 是否镇社区受理中心分管领导视角 */
 const isLeader = computed(() => userStore.roleInfo?.key === 'leader')
 
 /** 是否区农委 */
@@ -558,6 +449,45 @@ const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
 /** 区级政策监测(农委 / 人社 / 系统管理员) */
 const isDistrictPolicy = computed(() => isAgri.value || isHr.value || isAdmin.value)
 
+/** 村 / 镇四级:发放表 + 饼图共用同一套栅格 */
+const isTownLevelBoard = computed(
+  () => isVillage.value || isTown.value || isSocial.value || isLeader.value,
+)
+
+const payTableTitle = computed(() => {
+  if (isVillage.value) return '本村近半年发放人数与金额'
+  if (isTown.value) return '本镇各村发放人数与金额'
+  return '本月本镇各村发放人数与金额'
+})
+
+const payTableNameLabel = computed(() => (isVillage.value ? '发放周期' : '村居'))
+
+const payTableRows = computed(() =>
+  isVillage.value ? villagePayRows.value : townVillagePayRows.value,
+)
+
+const sidePieTitle = computed(() => (isTown.value ? '本月退养资格结构' : '本月业务状态占比'))
+
+const sidePieItems = computed(() =>
+  isTown.value ? townQualifyPieItems.value : (data.value?.statusPieMonth ?? []),
+)
+
+const workspaceTipsTitle = computed(() => {
+  if (isVillage.value) return '村级业务操作提示'
+  if (isTown.value) return '资格审批要点'
+  if (isSocial.value) return '业务办理要点'
+  if (isLeader.value) return '审批要点'
+  return '操作提示'
+})
+
+const workspaceTips = computed(() => {
+  if (isVillage.value) return data.value?.villageTips ?? []
+  if (isTown.value) return data.value?.townReview?.tips ?? []
+  if (isSocial.value) return data.value?.socialWorkbench?.tips ?? []
+  if (isLeader.value) return data.value?.leaderWorkbench?.tips ?? []
+  return []
+})
+
 /** 概览标题 */
 const overviewTitle = computed(() => {
   if (isVillage.value) return '本村人员与资格'
@@ -581,7 +511,7 @@ const roleTip = computed(() => {
   if (role.village) return '本村人员录入与提交资格'
   if (role.key === 'town') return '本镇按人审退养资格'
   if (role.key === 'social') return '本镇居保、清单、特殊业务与出盘回盘'
-  if (role.key === 'leader') return '审本镇月度清单与特殊业务'
+  if (role.key === 'leader') return '审本镇月度清单与特殊业务(镇社区受理中心分管领导)'
   if (role.town) return '本镇数据视角'
   return role.label
 })
@@ -600,22 +530,32 @@ const districtTipsTitle = computed(() => {
   return '工作要点'
 })
 
-/** 5.1.1.1 核心数据指标:总人数、本月新增/减少、本月发放金额(按镇/月由 workbench 汇总) */
+const tipsResetKey = computed(() => userStore.currentRole || '')
+
+const REGISTERED_COUNT_TIP =
+  '当前角色范围内人员档案底数(含暂停、死亡等仍在库人员),不是本月月补清单发放人数。'
+const MONTH_PAY_COUNT_TIP = '当前发放周期纳入月补清单的人数,与在册总人数口径不同。'
+const MONTH_AMOUNT_TIP = '当前发放周期月补清单应发合计。'
+
+/** 核心指标:在册总人数与本月发放人数分列,避免混用 */
 const statItems = computed(() => {
   const s = data.value?.stats
+  const payPeople = data.value?.batchInfo?.peopleCount
   if (!s) {
     return [
-      { label: '总人数', value: '-', unit: '人' },
-      { label: '本月新增', value: '-', unit: '人' },
-      { label: '本月减少', value: '-', unit: '人' },
-      { label: '本月发放金额', value: '-', unit: '元' },
+      { label: '在册总人数', value: '-', unit: '人', tip: REGISTERED_COUNT_TIP },
+      { label: '本月发放人数', value: '-', unit: '人', tip: MONTH_PAY_COUNT_TIP },
+      { label: '本月新增', value: '-', unit: '人', tip: '本月新纳入在册的人数。' },
+      { label: '本月减少', value: '-', unit: '人', tip: '本月因暂停/停止/死亡等退出发放的人数。' },
+      { label: '本月发放金额', value: '-', unit: '元', tip: MONTH_AMOUNT_TIP },
     ]
   }
   return [
-    { label: '总人数', value: s.normal, unit: '人' },
-    { label: '本月新增', value: s.monthIncrease ?? 0, unit: '人' },
-    { label: '本月减少', value: s.monthDecrease ?? 0, unit: '人' },
-    { label: '本月发放金额', value: formatAmount(s.amount), unit: '元' },
+    { label: '在册总人数', value: s.normal, unit: '人', tip: REGISTERED_COUNT_TIP },
+    { label: '本月发放人数', value: payPeople ?? 0, unit: '人', tip: MONTH_PAY_COUNT_TIP },
+    { label: '本月新增', value: s.monthIncrease ?? 0, unit: '人', tip: '本月新纳入在册的人数。' },
+    { label: '本月减少', value: s.monthDecrease ?? 0, unit: '人', tip: '本月因暂停/停止/死亡等退出发放的人数。' },
+    { label: '本月发放金额', value: formatAmount(s.amount), unit: '元', tip: MONTH_AMOUNT_TIP },
   ]
 })
 
@@ -706,12 +646,6 @@ const batchMetrics = computed(() => {
   if (isSocial.value) {
     const sw = data.value?.socialWorkbench
     return [
-      {
-        label: '全区月批',
-        value: info?.batchNo ?? '-',
-        icon: 'Ticket',
-        tone: 'tone-gold',
-      },
       {
         label: '待居保匹配',
         value: sw?.matchPendingCount ?? 0,
@@ -727,8 +661,16 @@ const batchMetrics = computed(() => {
         tone: 'tone-plain',
       },
       {
-        label: '待回盘/告警',
-        value: `${sw?.diskPendingCount ?? 0}/${sw?.alertCount ?? 0}`,
+        label: '待回盘',
+        value: sw?.diskPendingCount ?? 0,
+        unit: '批',
+        icon: 'Ticket',
+        tone: 'tone-gold',
+      },
+      {
+        label: '未关闭告警',
+        value: sw?.alertCount ?? 0,
+        unit: '条',
         icon: 'Bell',
         tone: 'tone-gold',
       },
@@ -737,12 +679,6 @@ const batchMetrics = computed(() => {
   if (isLeader.value) {
     const lw = data.value?.leaderWorkbench
     return [
-      {
-        label: '全区月批',
-        value: info?.batchNo ?? '-',
-        icon: 'Ticket',
-        tone: 'tone-gold',
-      },
       {
         label: '月补贴待审',
         value: lw?.pendingApprovalCount ?? 0,
@@ -758,8 +694,16 @@ const batchMetrics = computed(() => {
         tone: 'tone-plain',
       },
       {
-        label: '待出盘/告警',
-        value: `${lw?.issueReadyCount ?? 0}/${lw?.alertCount ?? 0}`,
+        label: '待出盘',
+        value: lw?.issueReadyCount ?? 0,
+        unit: '批',
+        icon: 'Ticket',
+        tone: 'tone-gold',
+      },
+      {
+        label: '未关闭告警',
+        value: lw?.alertCount ?? 0,
+        unit: '条',
         icon: 'Bell',
         tone: 'tone-gold',
       },
@@ -867,6 +811,9 @@ function buildBarOption(
     { offset: 1, color: '#2B73F8' },
   ],
 ): EChartsCoreOption {
+  const names = list.map((i) => i.name)
+  const many = names.length > 6
+  const endPercent = many ? Math.min(100, Math.round((6 / names.length) * 100)) : 100
   return {
     color: [CHART_COLOR],
     tooltip: {
@@ -876,13 +823,38 @@ function buildBarOption(
       textStyle: { color: '#333', fontSize: 12 },
       valueFormatter,
     },
-    grid: { left: 56, right: 16, top: 28, bottom: 36 },
+    grid: { left: 56, right: 16, top: 28, bottom: many ? 80 : 36 },
+    dataZoom: many
+      ? [
+          {
+            type: 'inside',
+            xAxisIndex: 0,
+            zoomOnMouseWheel: true,
+            moveOnMouseMove: true,
+          },
+          {
+            type: 'slider',
+            xAxisIndex: 0,
+            height: 18,
+            bottom: 6,
+            start: 0,
+            end: endPercent,
+            showDetail: false,
+            brushSelect: false,
+          },
+        ]
+      : undefined,
     xAxis: {
       type: 'category',
-      data: list.map((i) => i.name),
+      data: names,
       axisLine: { lineStyle: { color: '#e0d6c8' } },
-      // interval: 0 强制每个类目都显示,避免村名多时被 ECharts 自动隔点隐藏
-      axisLabel: { color: '#8c8c8c', fontSize: 12, interval: 0 },
+      axisLabel: {
+        color: '#8c8c8c',
+        fontSize: many ? 10 : 12,
+        interval: 0,
+        rotate: many ? 40 : 0,
+        hideOverlap: false,
+      },
       axisTick: { show: false },
     },
     yAxis: {
@@ -915,49 +887,155 @@ function buildBarOption(
   }
 }
 
-/** 村合作社:近半年各批次发放人数 */
-const villagePeopleBarOption = computed(() =>
-  buildBarOption(
-    data.value?.villageHalfYearPeopleChart ?? [],
-    '人数(人)',
-    (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
-    [
-      { offset: 0, color: '#a8b89a' },
-      { offset: 1, color: CHART_COLOR_GREEN },
+function formatHeadcount(n: number) {
+  return Number(n || 0).toLocaleString('zh-CN')
+}
+
+function formatPerCapita(amount: number, people: number) {
+  const p = Number(people || 0)
+  if (!p) return '—'
+  return formatAmount(Number(amount || 0) / p)
+}
+
+function mergePayRows(people: ChartBarItem[] = [], amounts: ChartBarItem[] = []) {
+  const map = new Map<string, { name: string; people: number; amount: number }>()
+  for (const p of people) {
+    map.set(p.name, { name: p.name, people: Number(p.value || 0), amount: 0 })
+  }
+  for (const a of amounts) {
+    const row = map.get(a.name) || { name: a.name, people: 0, amount: 0 }
+    row.amount = Number(a.value || 0)
+    map.set(a.name, row)
+  }
+  return [...map.values()]
+}
+
+function buildPayCombineOption(list: Array<{ name: string; people: number; amount: number }>): EChartsCoreOption {
+  return {
+    color: [CHART_COLOR, '#1A56C4'],
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(255,255,255,0.96)',
+      borderColor: '#d6e4ff',
+      textStyle: { color: '#333', fontSize: 12 },
+    },
+    legend: {
+      data: ['发放金额', '发放人数'],
+      top: 0,
+      left: 'center',
+      orient: 'horizontal',
+      itemGap: 18,
+      itemWidth: 12,
+      itemHeight: 8,
+      textStyle: { color: '#595959', fontSize: 12 },
+    },
+    grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: list.map((i) => i.name),
+      axisLine: { lineStyle: { color: '#e0d6c8' } },
+      axisLabel: { color: '#8c8c8c', fontSize: 11 },
+      axisTick: { show: false },
+    },
+    yAxis: [
+      {
+        type: 'value',
+        name: '金额(元)',
+        nameGap: 14,
+        nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
+        axisLabel: {
+          color: '#8c8c8c',
+          fontSize: 11,
+          formatter: (v: number) => (Math.abs(v) >= 10000 ? `${v / 10000}万` : String(v)),
+        },
+        splitLine: { lineStyle: { color: '#f0ebe3', type: 'dashed' } },
+        axisLine: { show: false },
+      },
+      {
+        type: 'value',
+        name: '人数',
+        nameGap: 14,
+        nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
+        axisLabel: { color: '#8c8c8c', fontSize: 11 },
+        splitLine: { show: false },
+        axisLine: { show: false },
+      },
     ],
-  ),
-)
+    series: [
+      {
+        name: '发放金额',
+        type: 'bar',
+        barMaxWidth: 28,
+        itemStyle: {
+          borderRadius: [2, 2, 0, 0],
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: '#4A8AFF' },
+              { offset: 1, color: '#2B73F8' },
+            ],
+          },
+        },
+        data: list.map((i) => i.amount),
+      },
+      {
+        name: '发放人数',
+        type: 'line',
+        yAxisIndex: 1,
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 6,
+        lineStyle: { width: 2, color: '#1A56C4' },
+        itemStyle: { color: '#1A56C4', borderWidth: 2, borderColor: '#fff' },
+        data: list.map((i) => i.people),
+      },
+    ],
+  }
+}
 
-/** 村合作社:近半年各批次发放金额 */
-const villageAmountBarOption = computed(() =>
-  buildBarOption(
-    data.value?.villageHalfYearAmountChart ?? [],
-    '金额(元)',
-    (v) => `¥${Number(v).toLocaleString('zh-CN')}`,
-  ),
+const villagePayRows = computed(() =>
+  mergePayRows(data.value?.villageHalfYearPeopleChart, data.value?.villageHalfYearAmountChart),
 )
-
-/** 镇经发中心:本批次各村发放金额 */
-const townVillageAmountOption = computed(() =>
-  buildBarOption(
-    data.value?.townVillageAmountChart ?? [],
-    '金额(元)',
-    (v) => `¥${Number(v).toLocaleString('zh-CN')}`,
+const townVillagePayRows = computed(() =>
+  mergePayRows(data.value?.townVillagePeopleChart, data.value?.townVillageAmountChart),
+)
+const townMonthPayRows = computed(() =>
+  mergePayRows(data.value?.monthPeopleChart, data.value?.monthAmountChart),
+)
+const townYearPayRows = computed(() =>
+  mergePayRows(data.value?.yearPeopleChart, data.value?.yearAmountChart),
+)
+const townMonthPayCombineOption = computed(() => buildPayCombineOption(townMonthPayRows.value))
+const townYearPayCombineOption = computed(() => buildPayCombineOption(townYearPayRows.value))
+const districtTownPayRows = computed(() =>
+  mergePayRows(
+    data.value?.districtOverview?.townPeopleChart,
+    data.value?.districtOverview?.townAmountChart,
   ),
 )
-
-/** 镇经发中心:本批次各村发放人数 */
-const townVillagePeopleOption = computed(() =>
-  buildBarOption(
-    data.value?.townVillagePeopleChart ?? [],
-    '人数(人)',
-    (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
-    [
-      { offset: 0, color: '#a8b89a' },
-      { offset: 1, color: CHART_COLOR_GREEN },
-    ],
+const districtHalfYearPayRows = computed(() =>
+  mergePayRows(
+    data.value?.districtOverview?.halfYearPeopleChart,
+    data.value?.districtOverview?.halfYearAmountChart,
   ),
 )
+const districtTownPayCombineOption = computed(() => buildPayCombineOption(districtTownPayRows.value))
+const districtHalfYearPayCombineOption = computed(() =>
+  buildPayCombineOption(districtHalfYearPayRows.value),
+)
+const townQualifyPieItems = computed(() => {
+  const pie = data.value?.townReview?.qualifyPie
+  if (pie?.length) return pie
+  const r = data.value?.townReview
+  return [
+    { name: '有土地退养', value: r?.withLandCount ?? 0 },
+    { name: '无土地有资格', value: r?.withoutLandCount ?? 0 },
+  ].filter((i) => i.value > 0)
+})
 
 /** 角色维度柱状图(标题与聚合维度均随角色变化) */
 const roleBarOption = computed(() =>
@@ -981,13 +1059,15 @@ const batchCompareOption = computed<EChartsCoreOption>(() => {
     },
     legend: {
       data: ['发放金额', '发放人数'],
-      top: 4,
-      right: 8,
+      top: 0,
+      left: 'center',
+      orient: 'horizontal',
+      itemGap: 18,
       itemWidth: 12,
       itemHeight: 8,
       textStyle: { color: '#595959', fontSize: 12 },
     },
-    grid: { left: 56, right: 48, top: 40, bottom: 28 },
+    grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
     xAxis: {
       type: 'category',
       data: list.map((i) => i.name),
@@ -999,14 +1079,20 @@ const batchCompareOption = computed<EChartsCoreOption>(() => {
       {
         type: 'value',
         name: '金额(元)',
+        nameGap: 14,
         nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
-        axisLabel: { color: '#8c8c8c', fontSize: 11 },
+        axisLabel: {
+          color: '#8c8c8c',
+          fontSize: 11,
+          formatter: (v: number) => (Math.abs(v) >= 10000 ? `${v / 10000}万` : String(v)),
+        },
         splitLine: { lineStyle: { color: '#f0ebe3', type: 'dashed' } },
         axisLine: { show: false },
       },
       {
         type: 'value',
         name: '人数',
+        nameGap: 14,
         nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
         axisLabel: { color: '#8c8c8c', fontSize: 11 },
         splitLine: { show: false },
@@ -1053,6 +1139,17 @@ function buildPieOption(
   list: { name: string; value: number }[],
   title: string,
 ): EChartsCoreOption {
+  if (!list.length) {
+    return {
+      title: {
+        text: '暂无数据',
+        left: 'center',
+        top: 'middle',
+        textStyle: { color: '#8c8c8c', fontSize: 13, fontWeight: 400 },
+      },
+      series: [],
+    }
+  }
   return {
     color: CHART_COLORS,
     tooltip: {
@@ -1114,15 +1211,6 @@ const districtTownPeopleOption = computed(() =>
   ),
 )
 
-/** 区级:各街镇金额 */
-const districtTownAmountOption = computed(() =>
-  buildBarOption(
-    data.value?.districtOverview?.townAmountChart ?? [],
-    '金额(元)',
-    (v) => `¥${Number(v).toLocaleString('zh-CN')}`,
-  ),
-)
-
 /** 区级:有土地退养 */
 const districtWithLandOption = computed(() =>
   buildBarOption(
@@ -1216,25 +1304,47 @@ async function onTodoClick(row: TodoItem) {
   void router.push(row.path || '/approvals')
 }
 
-/** 切换发放周期(写入 store,各角色工作台/业务页共用) */
+/** 切换发放周期(写入 store,各角色数据展板/业务页共用) */
 function onMonthChange(month: string | null) {
   if (!month) return
   userStore.setMonth(month)
 }
 
-/** 按当前角色与发放周期加载工作台(防抖 + 竞态序号) */
+/** 按当前角色与发放周期加载数据展板(防抖 + 竞态序号) */
 async function loadData(opts?: { forceLoading?: boolean }) {
   const seq = ++loadSeq
   // 仅首屏或切换角色/月份时盖 loading;定时静默刷新保留旧数据,避免整页长时间转圈
   const blockUi = opts?.forceLoading || !data.value
+  const needVillagePayments =
+    (isTown.value || isSocial.value || isLeader.value) && !isVillage.value
   if (blockUi) loading.value = true
   try {
-    const next = await fetchDashboard({
+    const lite = await fetchDashboard({
       roleKey: userStore.currentRole,
       currentMonth: userStore.currentMonth,
+      light: needVillagePayments,
     })
     if (seq !== loadSeq) return
-    data.value = next
+    data.value = lite
+    if (blockUi) loading.value = false
+    const hasVillagePay = (lite.townVillagePeopleChart || []).some((i) => Number(i.value) > 0)
+    if (needVillagePayments && !hasVillagePay) {
+      payTableLoading.value = true
+      try {
+        const charts = await fetchTownVillagePayTable({
+          roleKey: userStore.currentRole,
+          currentMonth: userStore.currentMonth,
+        })
+        if (seq !== loadSeq) return
+        if (data.value) {
+          data.value.townVillagePeopleChart = charts.people
+          data.value.townVillageAmountChart = charts.amount
+          data.value.townAmountChart = charts.amount
+        }
+      } finally {
+        if (seq === loadSeq) payTableLoading.value = false
+      }
+    }
   } finally {
     if (seq === loadSeq) loading.value = false
   }
@@ -1398,9 +1508,16 @@ watch(
   }
 }
 
-/** 村级 / 镇经发工作台:占满主内容区高度,避免下半部大块留白 */
+.dashboard-stat-note {
+  margin: -4px 0 12px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.55;
+}
+
+/** 村 / 镇展板:表格与饼图并排,不再把饼图单独拉满一行 */
 .dashboard-view--village {
-  min-height: calc(100vh - @header-height - @content-padding * 2);
+  min-height: 0;
 }
 
 /** 区农委 / 区人社局:全区监测工作台 */
@@ -1474,28 +1591,37 @@ watch(
 }
 
 .village-workspace {
-  flex: 1;
-  min-height: 520px;
-  display: grid;
-  grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.9fr);
+  display: flex;
+  flex-direction: column;
   gap: 12px;
-  align-items: stretch;
 
-  @media (max-width: 1100px) {
-    grid-template-columns: 1fr;
-    min-height: auto;
-  }
-
-  &__charts {
+  &__main {
     display: grid;
-    grid-template-rows: 1fr 1fr;
+    grid-template-columns: minmax(0, 1fr);
+    grid-template-areas: 'pay';
     gap: 12px;
-    min-height: 0;
+    align-items: start;
+
+    &.has-pie {
+      grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.9fr);
+      grid-template-areas: 'pay pie';
+    }
+
+    @media (max-width: 1100px) {
+      grid-template-columns: 1fr;
+      grid-template-areas: 'pay';
+
+      &.has-pie {
+        grid-template-areas:
+          'pay'
+          'pie';
+      }
+    }
 
     :deep(.content-panel) {
       margin-bottom: 0;
-      height: 100%;
       min-height: 0;
+      overflow: visible;
       display: flex;
       flex-direction: column;
     }
@@ -1506,124 +1632,36 @@ watch(
     }
   }
 
-  &__chart-host {
-    flex: 1;
-    min-height: 220px;
-    position: relative;
-
-    :deep(.chart-box) {
-      position: absolute;
-      inset: 0;
-      height: 100% !important;
-    }
+  &__pie {
+    grid-area: pie;
   }
 
-  &__tips-panel {
-    margin-bottom: 0 !important;
-    height: 100%;
-    min-height: 0;
-    display: flex;
-    flex-direction: column;
-
-    :deep(.page-panel__title),
-    :deep(.block-title) {
-      flex-shrink: 0;
-    }
+  &__pay {
+    grid-area: pay;
+  }
 
-    :deep(.village-tips) {
-      flex: 1;
+  &__table-panel {
+    :deep(.el-table) {
+      width: 100%;
     }
   }
-}
 
-.village-tips {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  gap: 0;
-  margin: 0;
-  padding: 2px 0;
-
-  &__item {
-    flex: 1;
+  &__trend {
     display: grid;
-    grid-template-columns: 28px minmax(0, 1fr);
+    grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 12px;
-    min-height: 0;
-  }
-
-  &__rail {
-    position: relative;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    padding-top: 2px;
-  }
-
-  &__index {
-    position: relative;
-    z-index: 1;
-    flex-shrink: 0;
-    width: 26px;
-    height: 26px;
-    border-radius: 50%;
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    font-size: 12px;
-    font-weight: 700;
-    color: #fff;
-    background: linear-gradient(145deg, @brand-gold-deep 0%, @primary-color 100%);
-    box-shadow: 0 1px 3px rgba(43, 115, 248, 0.28);
-  }
-
-  &__line {
-    flex: 1;
-    width: 2px;
-    margin: 4px 0 2px;
-    min-height: 12px;
-    background: linear-gradient(180deg, rgba(43, 115, 248, 0.55), rgba(43, 115, 248, 0.12));
-    border-radius: 1px;
-  }
 
-  &__card {
-    min-width: 0;
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    gap: 4px;
-    padding: 12px 14px;
-    margin-bottom: 10px;
-    background: linear-gradient(135deg, #f5f9ff 0%, #eef4ff 100%);
-    border: 1px solid #d6e4ff;
-    border-left: 3px solid @primary-color;
-    border-radius: 0 @radius-sm @radius-sm 0;
-    transition: border-color 0.15s, background 0.15s;
-
-    &:hover {
-      background: linear-gradient(135deg, #fff 0%, #eef4ff 100%);
-      border-color: rgba(43, 115, 248, 0.65);
+    @media (max-width: 1100px) {
+      grid-template-columns: 1fr;
     }
-  }
 
-  &__item:last-child &__card {
-    margin-bottom: 0;
-  }
-
-  &__title {
-    font-size: 13px;
-    font-weight: 700;
-    color: @text-primary;
-    line-height: 1.3;
-    letter-spacing: 0.02em;
+    :deep(.content-panel) {
+      margin-bottom: 0;
+    }
   }
 
-  &__desc {
-    margin: 0;
-    font-size: 12px;
-    color: @text-secondary;
-    line-height: 1.6;
+  &__chart-host {
+    height: 260px;
   }
 }
 

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

@@ -377,6 +377,15 @@ function onFailDialogClosed() {
 }
 
 async function onConfirmPaid(row: DiskFailPerson) {
+  try {
+    await ElMessageBox.confirm(
+      `确认「${row.personName}」这笔回盘失败已核实并发放成功?确认后会计入实发,并留下处理记录。`,
+      '确认发放',
+      { type: 'warning', confirmButtonText: '确认已发放', cancelButtonText: '取消' },
+    )
+  } catch {
+    return
+  }
   if (guardBusy(confirmingId)) return
   confirmingId.value = row.id
   try {

+ 133 - 3
src/views/insurance/InsuranceMatchView.vue

@@ -9,6 +9,14 @@
         >
           导出结果
         </el-button>
+        <el-button
+          v-if="!isHistoryTab"
+          :disabled="!canAddPeople || addingPerson"
+          :loading="addingPerson"
+          @click="openAddPerson()"
+        >
+          新增人员并匹配
+        </el-button>
         <el-button
           v-if="!isHistoryTab"
           type="primary"
@@ -27,10 +35,10 @@
           已提交领导及之后的本镇居保结果,只读,不可再次匹配。
         </template>
         <template v-else-if="isAdmin">
-          每月 1 号全区月批生成后自动居保。本页按街镇查看匹配结果;失败须由对应镇社区事务人工确认后才能提交该镇月度清单。
+          每月 15 号全区月批生成后自动居保。本页按街镇查看匹配结果;失败须由对应镇社区事务人工确认后才能提交该镇月度清单。
         </template>
         <template v-else>
-          核验全区当月批次的本镇人员。失败须在本页人工确认后,才能提交月度清单;已匹配完成的不会被全选勾中。
+          核验全区当月批次的本镇人员。失败须在本页人工确认后,才能提交月度清单;已匹配完成的不会被全选勾中。测算锁定前可用「新增人员并匹配」把本镇人员补进当月批并单人匹配。
         </template>
       </p>
       <el-tabs v-model="activeTab" @tab-change="onTabChange">
@@ -120,7 +128,7 @@
         </el-table-column>
         <el-table-column prop="submitter" label="提交人" width="110" />
         <el-table-column prop="submittedAt" label="提交时间" min-width="140" />
-        <el-table-column label="操作" width="320" align="center" fixed="right">
+        <el-table-column label="操作" width="380" align="center" fixed="right">
           <template #default="{ row }">
             <el-button
               link
@@ -158,6 +166,16 @@
             >
               匹配结果
             </el-button>
+            <el-button
+              v-if="canAddPeopleTo(row as VillageBatch)"
+              link
+              type="primary"
+              size="small"
+              :disabled="addingPerson"
+              @click="openAddPerson(row as VillageBatch)"
+            >
+              加人
+            </el-button>
             <el-button
               v-if="canViewMatchResult(row as VillageBatch)"
               link
@@ -257,6 +275,17 @@
       destroy-on-close
       @closed="onResultDialogClosed"
     >
+      <div v-if="!isHistoryTab" class="insurance-match-view__result-toolbar">
+        <el-button
+          size="small"
+          :disabled="!canAddPeopleTo(currentBatch) || addingPerson"
+          :loading="addingPerson"
+          @click="openAddPerson(currentBatch || undefined)"
+        >
+          新增人员并匹配
+        </el-button>
+        <span class="muted">未通过的可单人「重新匹配」或「人工确认」;测算锁定前可补人。</span>
+      </div>
       <el-table v-loading="resultLoading" :data="pagedResults" border stripe size="small" max-height="460">
         <el-table-column prop="name" label="姓名" width="80" />
         <el-table-column label="身份证" min-width="160">
@@ -345,6 +374,16 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <PersonPickerDialog
+      v-model:visible="personPickerVisible"
+      title="选择要纳入本批次的人员"
+      tip="测算锁定前可将本镇已有资格人员补进当月发放批,并立即单人居保匹配。"
+      :town="pickerTown"
+      :exclude-statuses="addPersonExcludeStatuses"
+      :exclude-ids="existingMemberIds"
+      @select="onPersonPicked"
+    />
   </div>
 </template>
 
@@ -356,8 +395,10 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import {
+  addBatchMembers,
   confirmInsuranceManual,
   exportInsuranceMatch,
+  fetchBatchMemberIds,
   fetchInsuranceBatchesPage,
   fetchInsuranceResultsPage,
   fetchInsuranceTaskLogs,
@@ -368,6 +409,7 @@ import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
+import PersonPickerDialog from '@/components/biz/PersonPickerDialog.vue'
 import { maskIdCard, formatAmount } from '@/utils/mask'
 import {
   displayBizText,
@@ -405,6 +447,19 @@ const stageOptions = computed(() =>
 
 const resultLoading = computed(() => resultPagerLoading.value)
 const matching = ref(false)
+const addingPerson = ref(false)
+const personPickerVisible = ref(false)
+const addTargetBatch = ref<VillageBatch | null>(null)
+const existingMemberIds = ref<string[]>([])
+const addPersonExcludeStatuses = [
+  '草稿',
+  '待提交资格',
+  '待镇经发审批',
+  '无资格',
+  '暂停发放',
+  '停止发放',
+  '已归档',
+]
 const rematchingId = ref('')
 const exporting = ref(false)
 const exportingBatchId = ref('')
@@ -562,6 +617,74 @@ function canViewMatchResult(row: VillageBatch) {
   return row.stage !== '居保状态匹配'
 }
 
+const lockedStages = ['分管领导审批', '待线下发放', '回盘信息确认', '已回盘确认', '已归档']
+
+function canAddPeopleTo(row: VillageBatch | null | undefined) {
+  if (!row || isHistoryTab.value) return false
+  if (row.calcLocked) return false
+  if (row.stage && lockedStages.includes(row.stage)) return false
+  return row.stage === '居保状态匹配' || row.stage === '比对结果已生成'
+}
+
+function targetAddBatch(prefer?: VillageBatch | null) {
+  if (prefer && canAddPeopleTo(prefer)) return prefer
+  if (currentBatch.value && canAddPeopleTo(currentBatch.value)) return currentBatch.value
+  if (selectedBatches.value.length === 1 && canAddPeopleTo(selectedBatches.value[0])) {
+    return selectedBatches.value[0]
+  }
+  return pagedBatches.value.find((b) => canAddPeopleTo(b)) || null
+}
+
+const canAddPeople = computed(() => Boolean(targetAddBatch()))
+const pickerTown = computed(() => scopedTownName() || addTargetBatch.value?.town || '')
+
+async function openAddPerson(row?: VillageBatch) {
+  const batch = targetAddBatch(row || null)
+  if (!batch) {
+    ElMessage.warning('请先选择一条未锁定的待办理批次')
+    return
+  }
+  if (!scopedTownName() && !batch.town) {
+    ElMessage.warning('请先选择街镇')
+    return
+  }
+  addTargetBatch.value = batch
+  existingMemberIds.value = []
+  try {
+    existingMemberIds.value = await fetchBatchMemberIds(batch.id)
+  } catch {
+    existingMemberIds.value = []
+  }
+  personPickerVisible.value = true
+}
+
+async function onPersonPicked(person: Person) {
+  const batch = addTargetBatch.value || targetAddBatch()
+  if (!batch) {
+    ElMessage.warning('未找到可加人的批次')
+    return
+  }
+  if (guardBusy(addingPerson)) return
+  addingPerson.value = true
+  try {
+    const addRes = await addBatchMembers(batch.id, [String(person.id)])
+    await triggerInsuranceMatch(batch.id, String(person.id))
+    ElMessage.success(
+      addRes.added > 0
+        ? `${person.name} 已纳入本批次并完成居保匹配`
+        : `${person.name} 已在批次中,已重新匹配`,
+    )
+    personPickerVisible.value = false
+    currentBatch.value = batch
+    resultDialogVisible.value = true
+    await Promise.all([loadVillageResults(batch), loadTaskLogs(batch.id), loadBatches()])
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '加人失败')
+  } finally {
+    addingPerson.value = false
+  }
+}
+
 function onSelectionChange(rows: VillageBatch[]) {
   selectedBatches.value = rows
 }
@@ -774,6 +897,13 @@ watch(
   padding: 8px 12px 0;
 }
 
+.insurance-match-view__result-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+
 .muted {
   color: @text-secondary;
   font-size: 12px;

+ 1 - 1
src/views/monthly/MonthlyPaymentView.vue

@@ -11,7 +11,7 @@
           查看各镇月补贴进度(镇统计,挂全区月批)。提交领导、出盘由对应镇社区事务办理;该镇特殊业务未齐时不可提交。
         </template>
         <template v-else>
-          明细为全区月批的本镇切片。须居保全部通过(或人工确认)且本镇当月特殊业务全部通过后,才可提交分管领导;通过后可出盘。
+          明细为全区月批的本镇切片。须居保全部通过(或人工确认)且本镇当月特殊业务全部通过后,才可提交镇社区受理中心分管领导;通过后可出盘。
         </template>
       </p>
       <div v-if="isAdmin" class="query-panel monthly-payment-view__filter">

+ 183 - 152
src/views/reports/ReportsView.vue

@@ -5,7 +5,7 @@
       <div class="reports-view__actions">
         <el-button type="primary" :loading="exporting" @click="onExportSummary">导出月度汇总</el-button>
         <el-button @click="onPrint">打印本页</el-button>
-        <el-button :loading="topicLoading" @click="resetTopicPage">刷新专题</el-button>
+        <el-button v-if="!isTown" :loading="topicLoading" @click="resetTopicPage">刷新专题</el-button>
         <el-button v-if="isDistrict" :loading="yoyLoading" @click="loadYoy">刷新同比环比</el-button>
       </div>
     </div>
@@ -20,8 +20,8 @@
       </p>
     </PagePanel>
 
-    <!-- 可研 5.1.9.2/.3:专题报表 -->
-    <PagePanel title="专题报表">
+    <!-- 镇经发不看专题报表 -->
+    <PagePanel v-if="!isTown" title="专题报表">
       <el-tabs v-model="topicTab" @tab-change="onTopicTabChange">
         <el-tab-pane label="发放异常" name="pay-abnormal" />
         <el-tab-pane label="人员变动" name="personnel-change" />
@@ -115,8 +115,8 @@
       />
     </PagePanel>
 
-    <!-- 镇经发 / 镇社区事务:本镇下属各村合计明细 -->
-    <PagePanel v-if="isTownScope" title="本镇下属各村汇总">
+    <!-- 镇社区事务 / 分管领导:本镇下属各村合计。镇经发不展示 -->
+    <PagePanel v-if="isTownScope && !isTown" title="本镇下属各村汇总">
       <el-table :data="pagedVillageSummary" border stripe size="small" style="width: 100%">
         <el-table-column prop="village" label="村居" min-width="120" />
         <el-table-column prop="batchCount" label="批次数" min-width="100" align="center" />
@@ -226,8 +226,8 @@
       </el-form>
     </div>
 
-    <!-- ========== 批次汇总 ========== -->
-    <PagePanel title="批次汇总">
+    <!-- 镇经发不办理批次,不展示批次汇总 -->
+    <PagePanel v-if="!isTown" title="批次汇总">
       <!-- 镇经发 / 镇社区事务:批次独立全字段查询 -->
       <div v-if="isTownScope" class="query-panel reports-view__inner-query">
         <div class="block-title">批次查询条件</div>
@@ -318,6 +318,14 @@
 
     <!-- ========== 镇经发 / 镇社区事务:人员台账(独立查询) ========== -->
     <PagePanel v-if="isTownScope" title="人员台账">
+      <template #extra>
+        <SensitiveRevealSwitch
+          v-model="revealSensitive"
+          :inactive-text="isTown ? '显示身份证' : '显示身份证和银行卡号'"
+          :active-text="isTown ? '已显示身份证' : '已显示证件卡号'"
+        />
+        <el-button type="primary" :loading="ledgerExporting" @click="onExportLedger">导出列表</el-button>
+      </template>
       <div class="query-panel reports-view__inner-query">
         <div class="block-title">人员查询条件</div>
         <el-form :model="peopleQuery" label-width="110px" size="small">
@@ -332,50 +340,28 @@
                 <el-input v-model="peopleQuery.idCard" placeholder="请输入" clearable />
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="批次号">
-                <el-input v-model="peopleQuery.batchNo" placeholder="请输入" clearable />
-              </el-form-item>
-            </el-col>
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="街镇">
                 <el-input :model-value="lockedTown" disabled />
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
+            <el-col v-if="!isTown" :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="村居">
                 <el-select v-model="peopleQuery.village" placeholder="全部" clearable style="width: 100%">
                   <el-option v-for="v in townVillageOptions" :key="v" :label="v" :value="v" />
                 </el-select>
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
+            <el-col v-if="!isTown" :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="银行卡">
                 <el-input v-model="peopleQuery.bankCard" placeholder="请输入" clearable />
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
+            <el-col v-if="!isTown" :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="开户行">
                 <el-input v-model="peopleQuery.bankName" placeholder="请输入" clearable />
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="社会保险登记码">
-                <el-input v-model="peopleQuery.socialInsuranceCode" placeholder="请输入" clearable />
-              </el-form-item>
-            </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="开始月份">
-                <el-date-picker
-                  v-model="peopleQuery.startMonth"
-                  type="month"
-                  placeholder="选择月份"
-                  value-format="YYYY-MM"
-                  clearable
-                  style="width: 100%"
-                />
-              </el-form-item>
-            </el-col>
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="人员状态">
                 <el-select v-model="peopleQuery.status" placeholder="全部" clearable style="width: 100%">
@@ -383,25 +369,6 @@
                 </el-select>
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="来源">
-                <el-select v-model="peopleQuery.source" placeholder="全部" clearable style="width: 100%">
-                  <el-option v-for="s in peopleSourceOptions" :key="s" :label="s" :value="s" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="居保状态">
-                <el-select
-                  v-model="peopleQuery.insuranceStatus"
-                  placeholder="全部"
-                  clearable
-                  style="width: 100%"
-                >
-                  <el-option v-for="s in insuranceStatusOptions" :key="s" :label="s" :value="s" />
-                </el-select>
-              </el-form-item>
-            </el-col>
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="退养资格">
                 <el-select
@@ -427,6 +394,16 @@
         </el-form>
       </div>
 
+      <el-tabs
+        v-if="isTown"
+        v-model="ledgerVillageTab"
+        type="card"
+        class="reports-view__village-sheets"
+        @tab-change="onLedgerVillageTab"
+      >
+        <el-tab-pane v-for="v in townVillageOptions" :key="v" :label="v" :name="v" />
+      </el-tabs>
+
       <el-table
         v-loading="peopleLoading || peopleTableLoading"
         :data="pagedPeople"
@@ -435,43 +412,73 @@
         size="small"
         style="width: 100%"
       >
-        <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
-        <el-table-column prop="name" label="姓名" min-width="90" />
-        <el-table-column label="身份证" min-width="160">
-          <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+        <el-table-column label="姓名" min-width="140">
+          <template #default="{ row }">
+            <PersonNameCell
+              :name="row.name"
+              :death-warning="row.deathWarning"
+              :death-status="row.deathStatus"
+              :death-date="row.deathDate"
+              :biz-status="row.bizStatus"
+              :status="row.status"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="身份证" min-width="180">
+          <template #default="{ row }">
+            <SensitiveValue
+              :key="`id-${row.id}`"
+              :row-key="row.id"
+              :value="row.idCard"
+              kind="idCard"
+              :revealed="revealSensitive"
+            />
+          </template>
         </el-table-column>
         <el-table-column prop="town" label="街镇" min-width="100" />
-        <el-table-column prop="village" label="村居" min-width="100" />
-        <el-table-column label="银行卡" min-width="150">
-          <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
+        <el-table-column v-if="!isTown" prop="village" label="村居" min-width="100" />
+        <el-table-column label="户籍性质" width="88" align="center">
+          <template #default="{ row }">{{ householdTypeLabel(row.householdType) }}</template>
         </el-table-column>
-        <el-table-column prop="bankName" label="开户行" min-width="120" show-overflow-tooltip />
-        <el-table-column prop="socialInsuranceCode" label="社会保险登记码" min-width="140" show-overflow-tooltip />
-        <el-table-column label="标准(元/月)" min-width="110" align="right">
-          <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+        <el-table-column v-if="!isTown" label="银行卡" min-width="170">
+          <template #default="{ row }">
+            <SensitiveValue
+              :key="`bank-${row.id}`"
+              :row-key="row.id"
+              :value="row.bankCard"
+              kind="bankCard"
+              :revealed="revealSensitive"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column v-if="!isTown" prop="bankName" label="开户行" min-width="120" show-overflow-tooltip />
+        <el-table-column label="社保卡号" min-width="140" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.socialInsuranceCode || '—' }}</template>
+        </el-table-column>
+        <el-table-column label="同城/异地" min-width="100" align="center">
+          <template #default="{ row }">{{ row.localOrRemote || '—' }}</template>
         </el-table-column>
-        <el-table-column prop="startMonth" label="开始月份" min-width="110" align="center" />
         <el-table-column label="状态" min-width="120" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.status" />
           </template>
         </el-table-column>
-        <el-table-column prop="source" label="来源" min-width="110" show-overflow-tooltip />
-        <el-table-column prop="insuranceStatus" label="居保状态" min-width="110" />
         <el-table-column label="退养资格" min-width="130" align="center">
           <template #default="{ row }">
             <el-tag
               v-if="row.retireQualifyType"
               size="small"
-              :type="row.retireQualifyType === '无土地有资格' ? 'warning' : 'success'"
+              :type="retireQualifyLabel(row.retireQualifyType) === '无土地有资格' ? 'warning' : 'success'"
               effect="plain"
             >
-              {{ row.retireQualifyType }}
+              {{ retireQualifyLabel(row.retireQualifyType) }}
             </el-tag>
             <span v-else class="muted">—</span>
           </template>
         </el-table-column>
-        <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
+        <el-table-column label="备注" min-width="120">
+          <template #default>—</template>
+        </el-table-column>
       </el-table>
       <TablePagination
         v-model:page="peoplePage"
@@ -482,6 +489,10 @@
 
     <!-- ========== 区农委 / 区人社局 / 系统管理员:人员台账 ========== -->
     <PagePanel v-if="!isTownScope" title="人员台账">
+      <template #extra>
+        <SensitiveRevealSwitch v-model="revealSensitive" />
+        <el-button type="primary" :loading="ledgerExporting" @click="onExportLedger">导出列表</el-button>
+      </template>
       <!-- 区农委对齐镇经发;区人社对齐社区事务;系统管理员具备全区操作权限,查询条件取全量 -->
       <div v-if="isAgri || isHr || isAdminDistrict" class="query-panel reports-view__inner-query">
         <div class="block-title">人员查询条件</div>
@@ -497,11 +508,6 @@
                 <el-input v-model="peopleQuery.idCard" placeholder="请输入" clearable />
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="批次号">
-                <el-input v-model="peopleQuery.batchNo" placeholder="请输入" clearable />
-              </el-form-item>
-            </el-col>
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="街镇">
                 <el-select
@@ -534,24 +540,7 @@
                   <el-input v-model="peopleQuery.bankName" placeholder="请输入" clearable />
                 </el-form-item>
               </el-col>
-              <el-col :xs="24" :sm="12" :md="8" :lg="6">
-                <el-form-item label="社会保险登记码">
-                  <el-input v-model="peopleQuery.socialInsuranceCode" placeholder="请输入" clearable />
-                </el-form-item>
-              </el-col>
             </template>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="开始月份">
-                <el-date-picker
-                  v-model="peopleQuery.startMonth"
-                  type="month"
-                  placeholder="选择月份"
-                  value-format="YYYY-MM"
-                  clearable
-                  style="width: 100%"
-                />
-              </el-form-item>
-            </el-col>
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="人员状态">
                 <el-select v-model="peopleQuery.status" placeholder="全部" clearable style="width: 100%">
@@ -559,26 +548,6 @@
                 </el-select>
               </el-form-item>
             </el-col>
-            <el-col :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="来源">
-                <el-select v-model="peopleQuery.source" placeholder="全部" clearable style="width: 100%">
-                  <el-option v-for="s in peopleSourceOptions" :key="s" :label="s" :value="s" />
-                </el-select>
-              </el-form-item>
-            </el-col>
-            <!-- 人社 / 系统管理员:居保状态 -->
-            <el-col v-if="isHr || isAdminDistrict" :xs="24" :sm="12" :md="8" :lg="6">
-              <el-form-item label="居保状态">
-                <el-select
-                  v-model="peopleQuery.insuranceStatus"
-                  placeholder="全部"
-                  clearable
-                  style="width: 100%"
-                >
-                  <el-option v-for="s in insuranceStatusOptions" :key="s" :label="s" :value="s" />
-                </el-select>
-              </el-form-item>
-            </el-col>
             <!-- 农委 / 系统管理员 / 人社:退养资格 -->
             <el-col :xs="24" :sm="12" :md="8" :lg="6">
               <el-form-item label="退养资格">
@@ -613,19 +582,41 @@
         size="small"
         style="width: 100%"
       >
-        <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
-        <el-table-column prop="name" label="姓名" min-width="90" />
-        <el-table-column label="身份证" min-width="160">
-          <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+        <el-table-column label="姓名" min-width="168">
+          <template #default="{ row }">
+            <PersonNameCell :person="row" />
+          </template>
+        </el-table-column>
+        <el-table-column label="身份证" min-width="176">
+          <template #default="{ row }">
+            <SensitiveValue
+              :key="`d-id-${row.id}`"
+              :row-key="row.id"
+              :value="row.idCard"
+              kind="idCard"
+              :revealed="revealSensitive"
+            />
+          </template>
         </el-table-column>
         <el-table-column prop="town" label="街镇" min-width="100" />
         <el-table-column prop="village" label="村居" min-width="100" />
+        <el-table-column label="户籍性质" width="88" align="center">
+          <template #default="{ row }">{{ householdTypeLabel(row.householdType) }}</template>
+        </el-table-column>
         <el-table-column
           v-if="isHr || isAdminDistrict"
           label="银行卡"
-          min-width="150"
+          min-width="168"
         >
-          <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
+          <template #default="{ row }">
+            <SensitiveValue
+              :key="`d-bank-${row.id}`"
+              :row-key="row.id"
+              :value="row.bankCard"
+              kind="bankCard"
+              :revealed="revealSensitive"
+            />
+          </template>
         </el-table-column>
         <el-table-column
           v-if="isHr || isAdminDistrict"
@@ -637,10 +628,12 @@
         <el-table-column
           v-if="isHr || isAdminDistrict"
           prop="socialInsuranceCode"
-          label="社会保险登记码"
+          label="社保卡号"
           min-width="140"
           show-overflow-tooltip
-        />
+        >
+          <template #default="{ row }">{{ row.socialInsuranceCode || '—' }}</template>
+        </el-table-column>
         <el-table-column
           v-if="isAgri || isAdminDistrict || isHr"
           label="退养资格"
@@ -651,54 +644,30 @@
             <el-tag
               v-if="row.retireQualifyType"
               size="small"
-              :type="row.retireQualifyType === '无土地有资格' ? 'warning' : 'success'"
+              :type="retireQualifyLabel(row.retireQualifyType) === '无土地有资格' ? 'warning' : 'success'"
               effect="plain"
             >
-              {{ row.retireQualifyType }}
+              {{ retireQualifyLabel(row.retireQualifyType) }}
             </el-tag>
             <span v-else class="muted">—</span>
           </template>
         </el-table-column>
-        <el-table-column
-          v-if="showMoney"
-          label="标准(元/月)"
-          min-width="110"
-          align="right"
-        >
-          <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
+        <el-table-column label="同城/异地" min-width="100" align="center">
+          <template #default="{ row }">{{ row.localOrRemote || '—' }}</template>
         </el-table-column>
-        <el-table-column
-          v-if="isHr || isAdminDistrict || isAgri"
-          prop="startMonth"
-          label="开始月份"
-          min-width="110"
-          align="center"
-        />
         <el-table-column label="状态" min-width="120" align="center">
           <template #default="{ row }">
             <StatusTag :status="row.status" />
           </template>
         </el-table-column>
-        <el-table-column
-          v-if="isAgri || isAdminDistrict"
-          prop="source"
-          label="来源"
-          min-width="110"
-          show-overflow-tooltip
-        />
-        <el-table-column
-          v-if="isHr || isAdminDistrict"
-          prop="insuranceStatus"
-          label="居保状态"
-          min-width="110"
-        />
         <el-table-column
           v-if="isAgri || isHr || isAdminDistrict"
-          prop="remark"
           label="备注"
           min-width="160"
           show-overflow-tooltip
-        />
+        >
+          <template #default> </template>
+        </el-table-column>
       </el-table>
       <TablePagination
         v-model:page="peoplePage"
@@ -717,6 +686,7 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import {
+  exportPersonnelLedger,
   exportReportSummary,
   fetchAlertsPage,
   fetchPeoplePage,
@@ -725,21 +695,26 @@ import {
   fetchReportTopicPage,
   fetchYoy,
 } from '@/api/biz'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
+import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
+import SensitiveValue from '@/components/common/SensitiveValue.vue'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
-import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
 import { splitByShare } from '@/utils/fundShare'
 import type { AlertItem, Person, VillageBatch } from '@/types/biz'
 import type { ReportTopicType } from '@/api/sjnmtybt/realBiz'
 import { regionName, resolveScopeIds } from '@/api/sjnmtybt/regionStore'
 import {
   displayBizText,
+  householdTypeLabel,
   personnelStatusLabel,
   refundProgressLabel,
+  retireQualifyLabel,
   labelOf,
 } from '@/utils/statusLabels'
 
@@ -767,6 +742,8 @@ const peopleLoading = ref(false)
 /** 首次进入 / 切角色:整页 loading(顶部汇总此前未挂 loading) */
 const pageLoading = ref(false)
 const exporting = ref(false)
+const ledgerExporting = ref(false)
+const revealSensitive = ref(false)
 const yoyLoading = ref(false)
 const topicTab = ref<ReportTopicType>('pay-abnormal')
 const yoyMeta = reactive({
@@ -1316,16 +1293,59 @@ function resetBatchQuery() {
   applyBatchFilter()
 }
 
+const ledgerVillageTab = ref('')
+
+function onLedgerVillageTab(name: string | number) {
+  peopleQuery.village = String(name || '')
+  void resetPeoplePage()
+}
+
+watch(townVillageOptions, (list) => {
+  if (!isTown.value || !list.length) return
+  if (list.includes(ledgerVillageTab.value)) return
+  ledgerVillageTab.value = list[0]
+  peopleQuery.village = list[0]
+  void resetPeoplePage()
+})
+
 function applyPeopleFilter() {
   void resetPeoplePage()
 }
 
+function ledgerQualifyCode() {
+  if (peopleQuery.retireQualifyType === '有土地退养') return 'WITH_LAND'
+  if (peopleQuery.retireQualifyType === '无土地有资格') return 'WITHOUT_LAND'
+  return undefined
+}
+
+async function onExportLedger() {
+  if (guardBusy(ledgerExporting)) return
+  ledgerExporting.value = true
+  try {
+    await exportPersonnelLedger({
+      name: peopleQuery.name || undefined,
+      idCard: peopleQuery.idCard || undefined,
+      town: peopleQueryTown() || undefined,
+      village: peopleQueryVillage() || undefined,
+      bizStatus: peopleStatusToCode(peopleQuery.status) || undefined,
+      retireQualifyType: ledgerQualifyCode(),
+      hideDraftUnlessVillage: !peopleQueryVillage(),
+      omitBank: isTown.value || undefined,
+    })
+    ElMessage.success('已导出人员台账')
+  } catch (e) {
+    ElMessage.error(e instanceof Error ? e.message : '导出失败')
+  } finally {
+    ledgerExporting.value = false
+  }
+}
+
 function resetPeopleQuery() {
   peopleQuery.name = ''
   peopleQuery.idCard = ''
   peopleQuery.batchNo = ''
   peopleQuery.town = ''
-  peopleQuery.village = ''
+  peopleQuery.village = isTown.value ? ledgerVillageTab.value : ''
   peopleQuery.bankCard = ''
   peopleQuery.bankName = ''
   peopleQuery.socialInsuranceCode = ''
@@ -1498,7 +1518,10 @@ async function loadAll() {
     resetTownSummaryPage()
     resetVillageSummaryPage()
     resetBatchPage()
-    await Promise.all([resetTopicPage(), resetPeoplePage()])
+    await Promise.all([
+      isTown.value ? Promise.resolve() : resetTopicPage(),
+      resetPeoplePage(),
+    ])
   } finally {
     peopleLoading.value = false
     pageLoading.value = false
@@ -1564,6 +1587,14 @@ watch(
   }
 }
 
+.reports-view__village-sheets {
+  margin: 4px 0 8px;
+
+  :deep(.el-tabs__header) {
+    margin-bottom: 8px;
+  }
+}
+
 .reports-view__toolbar {
   display: flex;
   align-items: flex-start;

+ 142 - 19
src/views/special/SpecialBusinessView.vue

@@ -6,7 +6,7 @@
           请先选择街镇。特殊业务领导通过后可调整本月全区发放明细;该镇当月未全部通过前,不可提交月度清单给分管领导。
         </template>
         <template v-else>
-          死亡、丧葬、停发/恢复、调标补发、多发退款在此受理,提交分管领导审批。全部通过前不可提交本镇月度清单。1 号后新人要进本月发放,须经本页审批通过后加人。
+          死亡、丧葬、停发/恢复、多发退款在此受理,提交镇社区受理中心分管领导审批。调标仅由区人社办理。全部通过前不可提交本镇月度清单。15 号后新人要进本月发放,须经本页审批通过后加人。
         </template>
       </p>
 
@@ -68,7 +68,11 @@
                 size="small"
                 height="100%"
               >
-                <el-table-column prop="name" label="姓名" width="80" />
+                <el-table-column label="姓名" width="140">
+                  <template #default="{ row }">
+                    <PersonNameCell :person="row" />
+                  </template>
+                </el-table-column>
                 <el-table-column label="身份证" min-width="160">
                   <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
                 </el-table-column>
@@ -93,12 +97,21 @@
                 </el-table-column>
                 <el-table-column prop="startMonth" label="起始月份" width="100" align="center" />
                 <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
-                <el-table-column label="操作" width="220" align="center" fixed="right">
+                <el-table-column label="操作" width="260" align="center" fixed="right">
                   <template #default="{ row }">
                     <el-button link type="primary" size="small" @click="openPersonDetail(row as Person)">
                       详情
                     </el-button>
                     <template v-if="canOperate && canMaintainStatus(row as Person)">
+                      <el-button
+                        v-if="canStopPerson(row as Person)"
+                        link
+                        type="warning"
+                        size="small"
+                        @click="openSpecialDialog('暂停发放', row as Person)"
+                      >
+                        暂停
+                      </el-button>
                       <el-button
                         v-if="canStopPerson(row as Person)"
                         link
@@ -315,8 +328,8 @@
           <TablePagination v-model:page="refundPage" v-model:page-size="refundPageSize" :total="refundTotal" />
         </el-tab-pane>
 
-        <!-- 调标补发申请:触发后按村 Tab 展示名单,并同步生成月补贴清单批次 -->
-        <el-tab-pane label="调标补发" name="adjust">
+        <!-- 调标仅区人社:街镇不再发起。管理员可查看历史补发名单。 -->
+        <el-tab-pane v-if="isAdmin" label="调标补发" name="adjust">
           <div v-if="isAdmin" class="query-panel special-business-view__filter">
             <el-form :inline="true" size="small">
               <el-form-item label="街镇" required>
@@ -333,15 +346,7 @@
             </el-form>
           </div>
           <div class="special-business-view__toolbar">
-            <el-button
-              v-if="canOperate"
-              type="primary"
-              :loading="adjustTriggering"
-              :disabled="isAdmin && !adminTown"
-              @click="onTriggerAdjustReissue"
-            >
-              发起调标补发
-            </el-button>
+            <p class="page-tip">调标仅由区人社在补助标准管理中办理,本页只可查看已生成的补发名单。</p>
           </div>
           <!-- <p class="special-business-view__sub-tip">
             触发后将在「月补贴清单」按本镇各村自动生成批次(待提交),并在下方按村展示调标补发名单(对接接口后由后端返回名单数据)。
@@ -380,7 +385,7 @@
               </el-tab-pane>
             </el-tabs>
           </template>
-          <el-empty v-else description="尚未发起调标补发申请,请点击上方按钮生成批次与名单" :image-size="72" />
+          <el-empty v-else description="调标由区人社办理后自动生成补发名单,本页只读查看" :image-size="72" />
         </el-tab-pane>
       </el-tabs>
     </PagePanel>
@@ -482,6 +487,30 @@
               />
             </el-form-item>
           </el-col>
+          <el-col :span="12">
+            <el-form-item label="证件类型">
+              <el-select v-model="deathForm.heirIdType" clearable class="is-editable" style="width: 100%" placeholder="选填">
+                <el-option label="居民身份证" value="居民身份证" />
+                <el-option label="护照" value="护照" />
+                <el-option label="其他" value="其他" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="银行名称">
+              <el-input v-model="deathForm.heirBankName" class="is-editable" placeholder="选填" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="户籍地址">
+              <el-input v-model="deathForm.heirHouseholdAddress" class="is-editable" placeholder="选填" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="联系地址">
+              <el-input v-model="deathForm.heirAddress" class="is-editable" placeholder="选填" />
+            </el-form-item>
+          </el-col>
         </el-row>
       </el-form>
       <template #footer>
@@ -519,12 +548,18 @@
           <el-descriptions-item label="审批意见" :span="2">
             {{ deathDetail.opinion || '—' }}
           </el-descriptions-item>
+          <el-descriptions-item label="注销申办日期">{{ deathDetail.cancelApplyDate || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="与逝者关系">{{ deathDetail.heirRelation || '—' }}</el-descriptions-item>
           <el-descriptions-item label="继承人" :span="2">
             {{ deathDetail.heirName || '—' }}
             <span v-if="deathDetail.heirIdCard" class="muted">
               ({{ maskIdCard(deathDetail.heirIdCard) }})
             </span>
           </el-descriptions-item>
+          <el-descriptions-item label="证件类型">{{ deathDetail.heirIdType || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="银行名称">{{ deathDetail.heirBankName || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="户籍地址">{{ deathDetail.heirHouseholdAddress || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="联系地址">{{ deathDetail.heirAddress || '—' }}</el-descriptions-item>
           <el-descriptions-item label="证明材料" :span="2">
             <FlowAttachments :file-urls="deathDetail.fileUrls" show-empty />
           </el-descriptions-item>
@@ -699,6 +734,24 @@
             <p class="special-business-view__field-tip">选定后街镇/村居自动带入。</p>
           </el-form-item>
         </template>
+        <template v-else-if="isStatusChangeForm">
+          <el-form-item label="选择人员" required>
+            <div class="person-pick-field">
+              <el-input
+                :model-value="specialPersonLabel"
+                readonly
+                placeholder="请点击选择本镇在册人员"
+              />
+              <el-button type="primary" @click="openSpecialPersonPicker">选择</el-button>
+              <el-button v-if="specialForm.personId" link type="danger" @click="clearSpecialPerson">
+                清除
+              </el-button>
+            </div>
+            <p class="special-business-view__field-tip">
+              必须点选人员。暂停、停止只改发放状态,不改金额;恢复发放的补发由系统按停发区间计算。
+            </p>
+          </el-form-item>
+        </template>
         <el-form-item v-else label="人员姓名" required>
           <el-input v-model="specialForm.personName" class="is-editable" />
         </el-form-item>
@@ -708,14 +761,14 @@
         <el-form-item label="村居" required>
           <el-input
             v-model="specialForm.village"
-            :disabled="isFuneralForm || isRefundForm"
-            :class="{ 'is-editable': !isFuneralForm && !isRefundForm }"
+            :disabled="isFuneralForm || isRefundForm || isStatusChangeForm"
+            :class="{ 'is-editable': !isFuneralForm && !isRefundForm && !isStatusChangeForm }"
           />
         </el-form-item>
         <el-form-item v-if="isFuneralForm" label="继承人">
           <el-input v-model="specialForm.heirName" class="is-editable" placeholder="选填" />
         </el-form-item>
-        <el-form-item label="金额(元)" :required="isFuneralForm">
+        <el-form-item v-if="!isStatusChangeForm" label="金额(元)" :required="isFuneralForm || isRefundForm">
           <el-input-number
             v-model="specialForm.amount"
             :min="0"
@@ -730,6 +783,29 @@
             }}
           </p>
         </el-form-item>
+        <template v-if="specialForm.type === '暂停发放'">
+          <el-form-item label="刑期开始">
+            <el-date-picker
+              v-model="specialForm.sentenceStart"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="服刑暂停时必填"
+              class="is-editable"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="刑期结束">
+            <el-date-picker
+              v-model="specialForm.sentenceEnd"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="服刑暂停时必填"
+              class="is-editable"
+              style="width: 100%"
+            />
+            <p class="special-business-view__field-tip">原因含「服刑」或「刑期」时,开始和结束都要填。审批通过后写入人员档案。</p>
+          </el-form-item>
+        </template>
         <el-form-item label="申请原因" required>
           <el-input
             v-model="specialForm.reason"
@@ -868,6 +944,7 @@ import TablePagination from '@/components/common/TablePagination.vue'
 import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import PersonPickerDialog from '@/components/biz/PersonPickerDialog.vue'
 import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
+import PersonNameCell from '@/components/common/PersonNameCell.vue'
 import type { UploadFile, UploadUserFile } from 'element-plus'
 import {
   advanceRefundProgress,
@@ -1185,6 +1262,10 @@ const deathForm = reactive({
   heirIdCard: '',
   heirBankCard: '',
   heirContact: '',
+  heirIdType: '',
+  heirBankName: '',
+  heirHouseholdAddress: '',
+  heirAddress: '',
 })
 
 const specialForm = reactive({
@@ -1194,6 +1275,8 @@ const specialForm = reactive({
   town: '',
   village: '',
   reason: '',
+  sentenceStart: '',
+  sentenceEnd: '',
   remark: '',
   amount: 0,
   heirName: '',
@@ -1273,6 +1356,9 @@ const personPickerExcludeIds = computed(() => {
 
 const isFuneralForm = computed(() => specialForm.type === '丧葬补贴')
 const isRefundForm = computed(() => specialForm.type === '多发退款')
+const isStatusChangeForm = computed(
+  () => specialForm.type === '暂停发放' || specialForm.type === '停止发放' || specialForm.type === '恢复发放',
+)
 
 const specialDialogTitle = computed(() => {
   if (isFuneralForm.value) return '丧葬补贴申请'
@@ -1311,6 +1397,10 @@ async function resetDeathForm() {
   deathForm.heirIdCard = ''
   deathForm.heirBankCard = ''
   deathForm.heirContact = ''
+  deathForm.heirIdType = ''
+  deathForm.heirBankName = ''
+  deathForm.heirHouseholdAddress = ''
+  deathForm.heirAddress = ''
   deathFileList.value = []
   deathRawFiles.value = []
 }
@@ -1614,7 +1704,9 @@ async function openSpecialDialog(type: string, person?: Person) {
   // 丧葬/退款:未选人时村居留空,选定后自动带入;其它类型可默认第一村
   specialForm.village =
     person?.village ||
-    (type === '丧葬补贴' || type === '多发退款' ? '' : regionVillages.value[0] || '')
+    (type === '丧葬补贴' || type === '多发退款' || type === '暂停发放' || type === '停止发放' || type === '恢复发放'
+      ? ''
+      : regionVillages.value[0] || '')
   specialForm.reason =
     type === '丧葬补贴' ? '申请丧葬补贴' : type === '多发退款' ? '多发退款' : ''
   specialForm.remark = ''
@@ -1627,6 +1719,8 @@ async function openSpecialDialog(type: string, person?: Person) {
     specialForm.amount = 0
   }
   specialForm.heirName = ''
+  specialForm.sentenceStart = ''
+  specialForm.sentenceEnd = ''
   specialForm.refundMethod = 'DEDUCT_NEXT'
   specialFileList.value = []
   specialRawFiles.value = []
@@ -1640,6 +1734,8 @@ function onSpecialDialogClosed() {
   specialForm.personId = ''
   specialForm.personName = ''
   specialForm.village = ''
+  specialForm.sentenceStart = ''
+  specialForm.sentenceEnd = ''
   specialForm.refundMethod = 'DEDUCT_NEXT'
   funeralAmountTip.value = ''
   funeralAmountLoading.value = false
@@ -1794,6 +1890,10 @@ async function onCreateDeath() {
       heirIdCard: deathForm.heirIdCard.trim() || undefined,
       heirBankCard: deathForm.heirBankCard.trim() || undefined,
       heirContact: deathForm.heirContact.trim() || undefined,
+      heirIdType: deathForm.heirIdType.trim() || undefined,
+      heirBankName: deathForm.heirBankName.trim() || undefined,
+      heirHouseholdAddress: deathForm.heirHouseholdAddress.trim() || undefined,
+      heirAddress: deathForm.heirAddress.trim() || undefined,
       fileUrls: uploaded.join(','),
     })) as {
       ok?: boolean
@@ -1993,6 +2093,12 @@ async function onCreateSpecial() {
       ElMessage.warning('请选择退款人员')
       return
     }
+  } else if (isStatusChangeForm.value) {
+    if (!specialForm.personId || !specialForm.personName) {
+      ElMessage.warning('请选择本镇在册人员,不能只填姓名')
+      return
+    }
+    specialForm.amount = 0
   } else if (!specialForm.personName) {
     ElMessage.warning('请填写人员姓名')
     return
@@ -2001,6 +2107,21 @@ async function onCreateSpecial() {
     ElMessage.warning('请填写申请原因')
     return
   }
+  if (specialForm.type === '暂停发放') {
+    const prison = /服刑|刑期/.test(specialForm.reason)
+    const start = specialForm.sentenceStart
+    const end = specialForm.sentenceEnd
+    if (prison || start || end) {
+      if (!start || !end) {
+        ElMessage.warning('服刑暂停须填写刑期开始和结束')
+        return
+      }
+      if (start > end) {
+        ElMessage.warning('刑期结束不能早于开始')
+        return
+      }
+    }
+  }
   if (isRefundForm.value) {
     if (specialForm.amount == null || Number(specialForm.amount) <= 0) {
       ElMessage.warning('请填写大于 0 的退款金额')
@@ -2051,6 +2172,8 @@ async function onCreateSpecial() {
       reason: reasonParts.join(';'),
       amount: specialForm.amount,
       heirName: specialForm.heirName || undefined,
+      sentenceStart: specialForm.type === '暂停发放' ? specialForm.sentenceStart || undefined : undefined,
+      sentenceEnd: specialForm.type === '暂停发放' ? specialForm.sentenceEnd || undefined : undefined,
       fileUrls,
       refundMethod: specialForm.type === '多发退款' ? specialForm.refundMethod : undefined,
     })

+ 181 - 120
src/views/status/StatusMaintenanceView.vue

@@ -3,6 +3,15 @@
     <PagePanel title="补助标准管理">
       <template #extra>
         <el-button
+          v-if="canDistrictAdjust"
+          type="warning"
+          size="small"
+          @click="openDistrictAdjust"
+        >
+          全区调标
+        </el-button>
+        <el-button
+          v-if="canMutateStandard"
           type="primary"
           size="small"
           :disabled="!canOpenCreate"
@@ -18,7 +27,7 @@
         :closable="false"
         show-icon
         class="page-alert"
-        title="标准变更默认需分管领导审批后生效。调标后由镇社区事务发起调标补发,通过后纳入发放。管理员直改走独立开关。"
+        :title="standardAlertTitle"
       />
       <el-alert
         v-if="pendingTip"
@@ -29,30 +38,8 @@
         :title="pendingTip"
       />
 
-      <!-- 系统管理员:按街镇筛选全区标准 -->
-      <div v-if="isAdmin" class="query-panel status-maintenance-view__filter">
-        <el-form :inline="true" size="small">
-          <el-form-item label="街镇">
-            <el-select
-              v-model="filterTown"
-              placeholder="全部街镇"
-              clearable
-              style="width: 160px"
-              @change="loadStandards"
-            >
-              <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
-            </el-select>
-          </el-form-item>
-          <el-form-item>
-            <el-button type="primary" @click="loadStandards">查询</el-button>
-            <el-button @click="resetTownFilter">重置</el-button>
-          </el-form-item>
-        </el-form>
-      </div>
-
       <div class="page-fill__table">
       <el-table v-loading="standardLoading" :data="pagedStandards" border stripe size="small" height="100%">
-        <el-table-column prop="town" label="街镇" width="100" />
         <el-table-column prop="year" label="年度" width="80" align="center" />
         <el-table-column label="月补助(元)" width="110" align="right">
           <template #default="{ row }">{{ formatAmount(row.monthly) }}</template>
@@ -74,6 +61,7 @@
           <template #default="{ row }">
             <template v-if="isCurrentStandard(row as SubsidyStandard)">
               <el-button
+                v-if="canMutateStandard"
                 link
                 type="primary"
                 size="small"
@@ -81,6 +69,7 @@
               >
                 调整标准
               </el-button>
+              <span v-else class="muted">现行标准</span>
             </template>
             <el-button
               v-else
@@ -113,7 +102,7 @@
 
     <el-dialog v-model="viewDialogVisible" title="查看补助标准" width="480px" destroy-on-close>
       <el-descriptions v-if="currentStandard" :column="1" border size="small">
-        <el-descriptions-item label="街镇">{{ currentStandard.town }}</el-descriptions-item>
+        <el-descriptions-item label="适用范围">全区统一</el-descriptions-item>
         <el-descriptions-item label="年度">{{ currentStandard.year }}</el-descriptions-item>
         <el-descriptions-item label="月补助">
           {{ formatAmount(currentStandard.monthly) }} 元
@@ -137,19 +126,6 @@
       @closed="onFormDialogClosed"
     >
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="110px" size="small">
-        <el-form-item label="街镇" prop="town">
-          <el-select
-            v-if="isAdmin && formMode === 'create'"
-            v-model="form.town"
-            placeholder="请选择街镇"
-            class="is-editable"
-            style="width: 100%"
-            @change="onCreateTownChange"
-          >
-            <el-option v-for="t in townOptions" :key="t" :label="t" :value="t" />
-          </el-select>
-          <el-input v-else :model-value="form.town" disabled />
-        </el-form-item>
         <el-form-item label="年度" prop="year">
           <el-date-picker
             v-model="form.year"
@@ -162,7 +138,7 @@
             style="width: 100%"
           />
           <p v-if="formMode === 'create'" class="status-maintenance-view__field-tip">
-            仅允许新增本年度({{ currentYear }})标准;历史年度与未来年度不可新增。本街镇本年度已有标准时请使用「调整标准」。
+            仅允许新增本年度({{ currentYear }})全区标准;本年度已有标准时请使用「调整标准」。
           </p>
         </el-form-item>
         <el-form-item label="月补助(元)" prop="monthly">
@@ -212,6 +188,67 @@
         <el-button type="primary" :loading="submitting" @click="submitForm">确定</el-button>
       </template>
     </el-dialog>
+
+    <el-dialog
+      v-model="districtVisible"
+      title="全区调标"
+      width="520px"
+      destroy-on-close
+      @closed="onDistrictClosed"
+    >
+      <el-alert
+        type="warning"
+        :closable="false"
+        show-icon
+        class="page-alert"
+        title="将按全区统一新标准落库,并按各镇生成调标补发批次。街镇登录时提示已调标。"
+      />
+      <el-form ref="districtFormRef" :model="districtForm" :rules="districtRules" label-width="120px" size="small">
+        <el-form-item label="新月补助(元)" prop="monthly">
+          <el-input-number
+            v-model="districtForm.monthly"
+            :min="0"
+            :precision="0"
+            :controls="false"
+            class="is-editable"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="新丧葬补助(元)" prop="funeral">
+          <el-input-number
+            v-model="districtForm.funeral"
+            :min="0"
+            :precision="0"
+            :controls="false"
+            class="is-editable"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="生效月份" prop="effectiveMonth">
+          <el-date-picker
+            v-model="districtForm.effectiveMonth"
+            type="month"
+            value-format="YYYYMM"
+            placeholder="选择生效月份"
+            class="is-editable"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="调整原因" prop="reason">
+          <el-input
+            v-model="districtForm.reason"
+            type="textarea"
+            :rows="2"
+            class="is-editable"
+            placeholder="请填写调标原因"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="districtVisible = false">取消</el-button>
+        <el-button type="warning" :loading="districtSubmitting" @click="submitDistrictAdjust">确认全区调标</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -221,18 +258,35 @@ import type { FormInstance, FormRules } 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, deleteStandard, fetchStandardPendingTip, fetchStandards, updateStandard } from '@/api/biz'
+import {
+  createStandard,
+  deleteStandard,
+  fetchStandardPendingTip,
+  fetchStandards,
+  submitDistrictYearlyAdjust,
+  updateStandard,
+} from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
-import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
+import { isPositiveAmount } from '@/utils/validators'
 import type { SubsidyStandard } from '@/types/biz'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
-const { townOptions } = useDynamicRegionOptions()
-const filterTown = ref('')
+const isHr = computed(() => userStore.roleInfo?.key === 'hr')
+const canMutateStandard = computed(() => isAdmin.value)
+const canDistrictAdjust = computed(() => isHr.value || isAdmin.value)
+const standardAlertTitle = computed(() => {
+  if (isHr.value) {
+    return '补助标准全区统一,不按街镇分别维护。调标仅由区人社办理;确认后更新全区标准并按各镇生成补发批次。'
+  }
+  if (!isAdmin.value) {
+    return '补助标准全区统一,由区人社调标。本页只可查看,不发起调标或补发。'
+  }
+  return '补助标准全区统一。标准变更默认需分管领导审批后生效;全区调标由区人社办理。'
+})
 
 const standards = ref<SubsidyStandard[]>([])
 const standardLoading = ref(false)
@@ -241,13 +295,36 @@ const pendingTip = ref('')
 
 const viewDialogVisible = ref(false)
 const formDialogVisible = ref(false)
+const districtVisible = ref(false)
+const districtSubmitting = ref(false)
+const districtFormRef = ref<FormInstance>()
+const districtForm = reactive({
+  monthly: undefined as number | undefined,
+  funeral: undefined as number | undefined,
+  effectiveMonth: '',
+  reason: '',
+})
+function positiveMoneyRule(label: string) {
+  return {
+    validator: (_r: unknown, v: unknown, cb: (e?: Error) => void) => {
+      if (!isPositiveAmount(v)) cb(new Error(`${label}须为大于 0 的金额,不能为 0、负数或中文`))
+      else cb()
+    },
+    trigger: 'blur' as const,
+  }
+}
+
+const districtRules: FormRules = {
+  monthly: [positiveMoneyRule('新月补助')],
+  funeral: [positiveMoneyRule('新丧葬补助')],
+  effectiveMonth: [{ required: true, message: '请选择生效月份', trigger: 'change' }],
+  reason: [{ required: true, message: '请填写调标原因', trigger: 'blur' }],
+}
 const formMode = ref<'create' | 'edit'>('create')
 const currentStandard = ref<SubsidyStandard | null>(null)
 const editingId = ref<number | null>(null)
 const formRef = ref<FormInstance>()
 
-const currentTown = computed(() => userStore.roleInfo?.town || '')
-
 /** 业务本年度:优先系统发放月,否则取自然年 */
 const currentYear = computed(() => {
   const m = String(userStore.currentMonth || '')
@@ -255,37 +332,22 @@ const currentYear = computed(() => {
   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)
+function hasCurrentYearStandard() {
+  return standards.value.some((s) => Number(s.year) === currentYear.value)
 }
 
 const createBlockedTip = computed(() => {
-  const town = isAdmin.value ? filterTown.value : currentTown.value
-  if (town && townHasCurrentYearStandard(town)) {
-    return `「${town}」${currentYear.value} 年度已有标准,禁止新增;请使用「调整标准」`
+  if (hasCurrentYearStandard()) {
+    return `${currentYear.value} 年度已有全区标准,禁止新增;请使用「调整标准」`
   }
   return ''
 })
 
-/** 列表页「新增标准」:管理员在「全部街镇」也可点,弹窗里再选镇 */
-const canOpenCreate = computed(() => {
-  if (isAdmin.value) {
-    if (filterTown.value) return !townHasCurrentYearStandard(filterTown.value)
-    return true
-  }
-  return Boolean(currentTown.value) && !townHasCurrentYearStandard(currentTown.value)
-})
+const canOpenCreate = computed(() => canMutateStandard.value && !hasCurrentYearStandard())
 
 function disableNonCurrentYear(date: Date) {
   return date.getFullYear() !== currentYear.value
@@ -301,10 +363,9 @@ const form = reactive({
 })
 
 const formRules: FormRules = {
-  town: [{ required: true, message: '请选择街镇', trigger: 'change' }],
   year: [{ required: true, message: '请选择年度', trigger: 'change' }],
-  monthly: [{ required: true, message: '请输入月补助', trigger: 'blur' }],
-  funeral: [{ required: true, message: '请输入丧葬补助', trigger: 'blur' }],
+  monthly: [positiveMoneyRule('月补助')],
+  funeral: [positiveMoneyRule('丧葬补助')],
   date: [{ required: true, message: '请选择生效日期', trigger: 'change' }],
   reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }],
 }
@@ -318,7 +379,7 @@ const {
 } = useClientPagination(standards)
 
 function resetForm() {
-  form.town = isAdmin.value ? filterTown.value || '' : currentTown.value
+  form.town = '全区'
   form.year = String(currentYear.value)
   form.monthly = undefined
   form.funeral = undefined
@@ -333,19 +394,8 @@ 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} 年度已有补助标准,禁止重复新增;如需变更请点击「调整标准」`,
-    )
+  if (hasCurrentYearStandard()) {
+    ElMessage.warning(`${currentYear.value} 年度已有全区补助标准,请使用「调整标准」`)
     return
   }
   formMode.value = 'create'
@@ -354,17 +404,6 @@ function openCreateDialog() {
   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
@@ -382,11 +421,6 @@ function onFormDialogClosed() {
   resetForm()
 }
 
-function resetTownFilter() {
-  filterTown.value = ''
-  loadStandards()
-}
-
 async function submitForm() {
   const valid = await formRef.value?.validate().catch(() => false)
   if (!valid) return
@@ -401,14 +435,8 @@ async function submitForm() {
       form.year = String(currentYear.value)
       return
     }
-    if (!form.town) {
-      ElMessage.warning('请选择街镇')
-      return
-    }
-    if (townHasCurrentYearStandard(form.town)) {
-      ElMessage.warning(
-        `「${form.town}」${currentYear.value} 年度已有补助标准,禁止重复新增;请使用「调整标准」`,
-      )
+    if (hasCurrentYearStandard()) {
+      ElMessage.warning(`${currentYear.value} 年度已有全区补助标准,请使用「调整标准」`)
       return
     }
   }
@@ -418,7 +446,7 @@ async function submitForm() {
   try {
     if (formMode.value === 'create') {
       const res = await createStandard({
-        town: form.town,
+        town: '全区',
         year: currentYear.value,
         monthly,
         funeral,
@@ -437,7 +465,7 @@ async function submitForm() {
       const res = await updateStandard({
         id: editingId.value,
         rawId: editing?.rawId,
-        town: form.town,
+        town: '全区',
         monthly,
         funeral,
         date: form.date,
@@ -460,8 +488,8 @@ async function submitForm() {
 
 async function onDeleteStandard(row: SubsidyStandard) {
   const tip = isCurrentStandard(row)
-    ? `「${row.town}」${row.monthly} 元是当前有效标准,删除后将自动恢复同镇上一档为有效。确认删除?`
-    : `确认删除「${row.town}」${row.year} 年标准(月补 ${row.monthly})?删除后列表不再展示。`
+    ? `月补 ${row.monthly} 元是当前全区有效标准,删除后将恢复上一档。确认删除?`
+    : `确认删除 ${row.year} 年全区标准(月补 ${row.monthly})?`
   try {
     await ElMessageBox.confirm(tip, '删除补助标准', {
       type: 'warning',
@@ -482,19 +510,53 @@ async function onDeleteStandard(row: SubsidyStandard) {
   })
 }
 
+function openDistrictAdjust() {
+  districtForm.monthly = undefined
+  districtForm.funeral = undefined
+  const m = String(userStore.currentMonth || '').replace('-', '')
+  districtForm.effectiveMonth = /^\d{6}$/.test(m) ? m : ''
+  districtForm.reason = ''
+  districtVisible.value = true
+}
+
+function onDistrictClosed() {
+  districtFormRef.value?.resetFields()
+}
+
+async function submitDistrictAdjust() {
+  const valid = await districtFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  if (guardBusy(districtSubmitting)) return
+  districtSubmitting.value = true
+  try {
+    await ElMessageBox.confirm(
+      '确认按新标准完成全区统一调标?将更新全区一份标准,并按各镇生成补发批次。',
+      '确认全区调标',
+      { type: 'warning', confirmButtonText: '确认调标', cancelButtonText: '取消' },
+    )
+    const res = await submitDistrictYearlyAdjust({
+      monthly: districtForm.monthly ?? 0,
+      funeral: districtForm.funeral ?? 0,
+      effectiveMonth: districtForm.effectiveMonth,
+      reason: districtForm.reason.trim(),
+    })
+    const tip = String((res as { tip?: string })?.tip || '全区调标已完成,已生成各镇补发批次')
+    ElMessage.success(tip)
+    districtVisible.value = false
+    await loadStandards()
+  } catch (e) {
+    if (e === 'cancel' || e === 'close') return
+    ElMessage.error(e instanceof Error ? e.message : '全区调标失败')
+  } finally {
+    districtSubmitting.value = false
+  }
+}
+
 async function loadStandards() {
   standardLoading.value = true
   try {
-    if (isAdmin.value) {
-      const all = await fetchStandards()
-      standards.value = filterTown.value
-        ? all.filter((s) => s.town === filterTown.value)
-        : all
-      pendingTip.value = await fetchStandardPendingTip(filterTown.value || undefined)
-    } else {
-      standards.value = await fetchStandards(currentTown.value || undefined)
-      pendingTip.value = await fetchStandardPendingTip(currentTown.value || undefined)
-    }
+    standards.value = await fetchStandards()
+    pendingTip.value = await fetchStandardPendingTip()
     resetStandardPage()
   } finally {
     standardLoading.value = false
@@ -506,7 +568,6 @@ onMounted(loadStandards)
 watch(
   () => userStore.currentRole,
   () => {
-    filterTown.value = ''
     loadStandards()
   },
 )

+ 18 - 12
src/views/system/SystemAdminView.vue

@@ -251,16 +251,16 @@
         <el-descriptions-item label="操作人">{{ logDetail.user || '—' }}</el-descriptions-item>
         <el-descriptions-item label="角色">{{ logDetail.role || '—' }}</el-descriptions-item>
         <el-descriptions-item label="机构" :span="2">{{ logDetail.operatorOrg || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="模块">{{ logDetail.module || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="操作内容">{{ logDetail.action || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="结果">{{ resultLabelOf(logDetail.result) || logDetail.result || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="流程阶段">{{ logDetail.stage || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="模块">{{ displayBizText(logDetail.module) }}</el-descriptions-item>
+        <el-descriptions-item label="操作内容">{{ processActionLabelOf(logDetail.action) }}</el-descriptions-item>
+        <el-descriptions-item label="结果">{{ resultLabelOf(logDetail.result) || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="流程阶段">{{ displayBizText(logDetail.stage) }}</el-descriptions-item>
         <el-descriptions-item label="批次号">{{ logDetail.batchNo || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="人员ID">{{ logDetail.personnelId || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="意见" :span="2">{{ logDetail.opinion || '—' }}</el-descriptions-item>
-        <el-descriptions-item label="备注" :span="2">{{ logDetail.remarks || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="关联人员">{{ logDetail.personnelId ? '已关联人员档案' : '无' }}</el-descriptions-item>
+        <el-descriptions-item label="意见" :span="2">{{ displayRemark(logDetail.opinion) }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ displayRemark(logDetail.remarks) }}</el-descriptions-item>
         <el-descriptions-item v-if="logDetail.fileUrls" label="附件" :span="2">
-          {{ logDetail.fileUrls }}
+          <FlowAttachments :file-urls="logDetail.fileUrls" />
         </el-descriptions-item>
       </el-descriptions>
     </el-dialog>
@@ -270,6 +270,7 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import FlowAttachments from '@/components/biz/FlowAttachments.vue'
 import StatCards from '@/components/common/StatCards.vue'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
@@ -291,7 +292,7 @@ import { useClientPagination } from '@/composables/useClientPagination'
 import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
-import { processActionLabelOf, resultLabelOf } from '@/utils/statusLabels'
+import { displayBizText, displayRemark, processActionLabelOf, resultLabelOf } from '@/utils/statusLabels'
 import type { AccountItem, OpLog } from '@/types/biz'
 
 const userStore = useUserStore()
@@ -445,8 +446,13 @@ function openAccountDialog(row?: AccountItem) {
 }
 
 async function onSaveAccount() {
-  if (!accountForm.username.trim()) {
-    ElMessage.warning('请填写登录账号')
+  const username = accountForm.username.trim()
+  if (!/^[A-Za-z][A-Za-z0-9_]{2,31}$/.test(username)) {
+    ElMessage.warning('登录账号须字母开头,3–32 位字母、数字或下划线,不能填中文或空格')
+    return
+  }
+  if (!accountForm.id && (!accountForm.password || accountForm.password.length < 6 || /\s/.test(accountForm.password))) {
+    ElMessage.warning('初始密码至少 6 位,且不能含空格')
     return
   }
   if (accountForm.id) {
@@ -458,7 +464,7 @@ async function onSaveAccount() {
   accountSaving.value = true
   try {
     await saveAccount({
-      username: accountForm.username.trim(),
+      username,
       displayName: accountForm.displayName.trim() || accountForm.username.trim(),
       role: accountForm.role,
       password: accountForm.password,