Просмотр исходного кода

敏感数据支持查看功能,数据异常计算BUG修复

DESKTOP-6LTVLN7\Liumouren 3 недель назад
Родитель
Сommit
9637f24458

+ 2 - 2
public/config/config.js

@@ -85,7 +85,7 @@ window.AppConfig = {
       '/alerts',
       '/reports',
     ],
-    leader: ['/dashboard', '/approvals', '/reports'],
+    leader: ['/dashboard', '/approvals', '/reports', '/alerts'],
     agri: ['/dashboard', '/reports'],
     hr: ['/dashboard', '/reports', '/alerts'],
     admin: [
@@ -107,5 +107,5 @@ window.AppConfig = {
   },
 
   /** 告警只读角色(不可处理关闭) */
-  alertReadonlyRoles: ['hr'],
+  alertReadonlyRoles: ['hr', 'leader'],
 }

+ 11 - 0
src/api/biz.ts

@@ -107,6 +107,11 @@ export function fetchPendingSpecialCount(town?: string) {
   return real.fetchPendingSpecialCount(town)
 }
 
+export function fetchUnresolvedInsuranceCount(batchId: string) {
+  if (useMock()) return delay(0)
+  return real.fetchUnresolvedInsuranceCount(batchId)
+}
+
 export function fetchPeople(params?: {
   batchNo?: string
   town?: string
@@ -342,6 +347,12 @@ export function fetchBatchMemberPeoplePage(
     parentBatchId?: string
     pageNum?: number
     pageSize?: number
+    name?: string
+    idCard?: string
+    sortBy?: string
+    sortOrder?: string
+    onlyFuneral?: boolean
+    onlySupplement?: boolean
   },
 ) {
   if (useMock()) {

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

@@ -170,14 +170,26 @@ export function fmtTime(ts?: number | string | null): string {
   return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
 }
 
+/** 清洗导入噪音备注(如「源表月补=24868」),不改库仅展示层 */
+export function sanitizePersonnelRemark(raw?: string | null): string {
+  if (!raw) return ''
+  let s = String(raw)
+  s = s.replace(/(?:^|[;;,\s]*)源表月补\s*[=::]\s*[\d.]+/gi, '')
+  s = s.replace(/(?:^|[;;,\s]*)源表[^=;;]{0,8}\s*[=::]\s*[\d.]+/gi, '')
+  s = s.replace(/^[;;,\s]+|[;;,\s]+$/g, '')
+  s = s.replace(/[;;]{2,}/g, ';')
+  return s.trim()
+}
+
 export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): Person {
   const biz = String(raw.bizStatus || '')
   const ins = String(raw.insuranceMatchResult || raw.insuranceStatus || '')
   // 人员业务状态只跟枚举走,禁止再把 ABNORMAL 伪装成「待人工确认」
   // 「待人工确认」是居保匹配失败后的办理动作(镇社区事务中心人工通过),不是独立状态码
   let status = PERSON_STATUS_LABEL[biz] || biz || '草稿'
-  // 退养资格:正式字段优先,备注兼容历史数据
-  const remarks = String(raw.remarks || '')
+  // 退养资格:正式字段优先,备注兼容历史数据(用原文匹配;展示用清洗后)
+  const remarksRaw = String(raw.remarks || '')
+  const remarks = sanitizePersonnelRemark(remarksRaw)
   const qualifyCode = String(raw.retireQualifyType || '').toUpperCase()
   let retireQualifyType: Person['retireQualifyType']
   if (qualifyCode === 'UNSET' || qualifyCode === 'NONE' || qualifyCode === 'CLEARED') {
@@ -188,11 +200,11 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     retireQualifyType = '无土地有资格'
   } else if (qualifyCode === 'WITH_LAND' || qualifyCode === '有土地退养') {
     retireQualifyType = '有土地退养'
-  } else if (remarks.includes('INELIGIBLE') || remarks.includes('无资格') || remarks.includes('不具备退养')) {
+  } else if (remarksRaw.includes('INELIGIBLE') || remarksRaw.includes('无资格') || remarksRaw.includes('不具备退养')) {
     retireQualifyType = '无资格'
-  } else if (remarks.includes('无土地') || remarks.includes('无承包地')) {
+  } else if (remarksRaw.includes('无土地') || remarksRaw.includes('无承包地')) {
     retireQualifyType = '无土地有资格'
-  } else if (remarks.includes('有土地') || remarks.includes('资格已核验')) {
+  } else if (remarksRaw.includes('有土地') || remarksRaw.includes('资格已核验')) {
     retireQualifyType = '有土地退养'
   }
   // 待提交资格尚未生效,不展示上次通过留下的有地/无地
@@ -228,6 +240,7 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     source: String(raw.source || ''),
     insuranceStatus: INSURANCE_LABEL[ins] || String(raw.insuranceStatus || '未匹配'),
     remark: remarks || undefined,
+    remarkRaw: remarksRaw.trim() || undefined,
     retireQualifyType,
     phoneNumber: raw.phoneNumber != null ? String(raw.phoneNumber) : undefined,
     address: raw.address != null ? String(raw.address) : undefined,
@@ -268,6 +281,10 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
     landSubsidyIncrease: numOrU(raw.landSubsidyIncrease),
     createTime: fmtTime(raw.createTime as number | string) || undefined,
     updateTime: fmtTime(raw.updateTime as number | string) || undefined,
+    updateTimeMs: (() => {
+      const n = Number(raw.updateTime)
+      return Number.isFinite(n) && n > 0 ? n : undefined
+    })(),
   }
 }
 
@@ -552,6 +569,7 @@ export function mapSpecial(raw: Record<string, unknown>): SpecialBiz {
     village: regionName(String(raw.villageId || '')),
     type: SPECIAL_TYPE_LABEL[bizType] || processActionLabelOf(bizType) || '特殊业务',
     personName: String(raw.name || ''),
+    personnelId: raw.personnelId ? String(raw.personnelId) : undefined,
     reason: String(raw.reason || ''),
     amount: Number(raw.amount || 0),
     status: String(raw.approveStatus || ''),

+ 85 - 72
src/api/sjnmtybt/realBiz.ts

@@ -63,6 +63,7 @@ import {
   monthToPayMonth,
   parseApprovalId,
   personIdStr,
+  sanitizePersonnelRemark,
   specialTypeToCode,
   isPayMonthLike,
 } from './mappers'
@@ -740,13 +741,13 @@ export async function fetchPeoplePage(
   params?: PeoplePageParams,
 ): Promise<{ list: Person[]; total: number; pageNum: number; pageSize: number }> {
   await ensureTowns()
-  await ensureAllVillages()
   const cfg = getAppConfig()
   const role = cfg.roles.find((r) => r.key === cfg.currentRole)
   const town = params?.town || role?.town
   const village = params?.village || role?.village
   const { townId, villageId } = await resolveScopeIds(town, village)
-  if (townId) await ensureVillages(townId)
+  if (townId) await ensureVillages(town || townId)
+  else await ensureAllVillages()
   const pageNum = params?.pageNum || 1
   const pageSize = params?.pageSize || 10
   const res = await post<PageResult<Record<string, unknown>>>('/personnel/page', {
@@ -930,45 +931,57 @@ export async function savePerson(payload: Partial<Person>) {
   const blank = (v: unknown) => (v == null || v === '' ? undefined : String(v).trim())
   const numOrU = (v: unknown) =>
     v == null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v)
-  await post('/personnel', {
-    id: isEdit ? String(payload.id) : undefined,
-    name: payload.name,
-    idNumber: payload.idCard,
-    townId,
-    villageId,
-    bankCardNumber: payload.bankCard,
-    bankName: blank(payload.bankName),
-    bankType: blank(payload.bankType),
-    bankBranchCode: blank(payload.bankBranchCode),
-    socialInsuranceCode: blank(payload.socialInsuranceCode),
-    phoneNumber: blank(payload.phoneNumber),
-    address: blank(payload.address),
-    householdType: blank(payload.householdType),
-    villageCode: blank(payload.villageCode),
-    entrustPlanCode: blank(payload.entrustPlanCode),
-    entrustPlanName: blank(payload.entrustPlanName),
-    monthlyStandard: numOrU(payload.standard),
-    baseAmount: numOrU(payload.baseAmount),
-    oneTimeAmount: numOrU(payload.oneTimeAmount),
-    landSubsidyIncrease: numOrU(payload.landSubsidyIncrease),
-    funeralDiffAmount: numOrU(payload.funeralDiffAmount),
-    enjoyStartMonth: monthToPayMonth(payload.startMonth) || (isEdit ? undefined : currentPayMonth()),
-    enjoyEndMonth: monthToPayMonth(payload.enjoyEndMonth) || undefined,
-    supplementStartMonth: monthToPayMonth(payload.supplementStartMonth) || undefined,
-    nextPayStartMonth: monthToPayMonth(payload.nextPayStartMonth) || undefined,
-    payeeName: blank(payload.payeeName) || blank(payload.name),
-    // 可清空字段:编辑时始终传字符串,空串表示明确清空(勿用 blank→undefined,否则后端保留旧值)
-    deathDate: isEdit ? String(payload.deathDate ?? '').trim() : blank(payload.deathDate),
-    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),
-    heirBankCard: isEdit ? String(payload.heirBankCard ?? '').trim() : blank(payload.heirBankCard),
-    heirPhone: isEdit ? String(payload.heirPhone ?? '').trim() : blank(payload.heirPhone),
-    source: payload.source || (isEdit ? '管理员修改' : '村级录入'),
-    remarks: payload.remark,
-    operatorRoleKey: cfg.currentRole,
-  })
-  return { ok: true }
+  const raw = await post<Record<string, unknown>>(
+    '/personnel',
+    {
+      id: isEdit ? String(payload.id) : undefined,
+      name: payload.name,
+      idNumber: payload.idCard,
+      townId,
+      villageId,
+      bankCardNumber: payload.bankCard,
+      bankName: blank(payload.bankName),
+      bankType: blank(payload.bankType),
+      bankBranchCode: blank(payload.bankBranchCode),
+      socialInsuranceCode: blank(payload.socialInsuranceCode),
+      phoneNumber: blank(payload.phoneNumber),
+      address: blank(payload.address),
+      householdType: blank(payload.householdType),
+      villageCode: blank(payload.villageCode),
+      entrustPlanCode: blank(payload.entrustPlanCode),
+      entrustPlanName: blank(payload.entrustPlanName),
+      monthlyStandard: numOrU(payload.standard),
+      baseAmount: numOrU(payload.baseAmount),
+      oneTimeAmount: numOrU(payload.oneTimeAmount),
+      landSubsidyIncrease: numOrU(payload.landSubsidyIncrease),
+      funeralDiffAmount: numOrU(payload.funeralDiffAmount),
+      enjoyStartMonth: monthToPayMonth(payload.startMonth) || (isEdit ? undefined : currentPayMonth()),
+      enjoyEndMonth: monthToPayMonth(payload.enjoyEndMonth) || undefined,
+      supplementStartMonth: monthToPayMonth(payload.supplementStartMonth) || undefined,
+      nextPayStartMonth: monthToPayMonth(payload.nextPayStartMonth) || undefined,
+      payeeName: blank(payload.payeeName) || blank(payload.name),
+      deathDate: isEdit ? String(payload.deathDate ?? '').trim() : blank(payload.deathDate),
+      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),
+      heirBankCard: isEdit ? String(payload.heirBankCard ?? '').trim() : blank(payload.heirBankCard),
+      heirPhone: isEdit ? String(payload.heirPhone ?? '').trim() : blank(payload.heirPhone),
+      source: payload.source || (isEdit ? '管理员修改' : '村级录入'),
+      remarks: (() => {
+        const edited = payload.remark
+        const raw = payload.remarkRaw
+        if (raw != null && sanitizePersonnelRemark(raw) === String(edited || '').trim()) {
+          return raw
+        }
+        return edited
+      })(),
+      operatorRoleKey: cfg.currentRole,
+      expectedUpdateTime: isEdit ? payload.updateTimeMs : undefined,
+    },
+    { skipCache: true },
+  )
+  clearGetCache()
+  return raw ? mapPerson(raw) : null
 }
 
 export async function submitRetireQualify(
@@ -1334,6 +1347,12 @@ export async function fetchBatchMemberPeoplePage(
     parentBatchId?: string
     pageNum?: number
     pageSize?: number
+    name?: string
+    idCard?: string
+    sortBy?: string
+    sortOrder?: string
+    onlyFuneral?: boolean
+    onlySupplement?: boolean
   },
 ): Promise<{
   list: BatchMemberPerson[]
@@ -1359,6 +1378,12 @@ export async function fetchBatchMemberPeoplePage(
     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 details = detailsRes?.list || []
   const batchNo = opts?.batchNo || currentPayMonth()
@@ -1724,6 +1749,17 @@ export async function submitToLeader(batchId: string) {
   return { ok: true }
 }
 
+/** 批次内居保未闭环人数(未匹配/失败等;提交领导前校验) */
+export async function fetchUnresolvedInsuranceCount(batchId: string): Promise<number> {
+  if (!batchId) return 0
+  const res = await get<{ count?: number }>(
+    `/batches/${encodeURIComponent(batchId)}/unresolved-insurance-count`,
+    undefined,
+    { silent: true },
+  )
+  return Number(res?.count || 0)
+}
+
 export async function exportCompareList(_batchId?: string) {
   return {
     ok: false as const,
@@ -2609,19 +2645,26 @@ export async function createDeathRecord(payload: {
     const reasonParts = [`死亡登记;死亡日期 ${payload.deathDate}`]
     if (payload.heirRelation) reasonParts.push(`与逝者关系:${payload.heirRelation}`)
     // 先落继承人信息(含关系),审批通过后再改死亡状态
+    // 必须带回逝者银行卡:后端编辑时 null 会触发 putKeep 保留,但缺字段时部分路径仍可能清空
     if (detail?.id) {
+      const ut = Number(detail.updateTime)
       await post('/personnel', {
         id: personnelId,
         name: detail.name || payload.personName,
         idNumber: detail.idNumber || payload.idCard,
         townId: detail.townId || townId,
         villageId: detail.villageId,
+        bankCardNumber: detail.bankCardNumber || undefined,
+        bankName: detail.bankName || undefined,
+        bankType: detail.bankType || undefined,
+        bankBranchCode: detail.bankBranchCode || undefined,
         heirName: payload.heirName || undefined,
         heirRelation: payload.heirRelation || undefined,
         heirIdNumber: payload.heirIdCard || undefined,
         heirBankCard: payload.heirBankCard || undefined,
         heirPhone: payload.heirContact || undefined,
         source: '死亡登记',
+        expectedUpdateTime: Number.isFinite(ut) && ut > 0 ? ut : undefined,
       }).catch(() => undefined)
     }
     const created = await post<Record<string, unknown>>('/special-biz', {
@@ -3608,37 +3651,7 @@ export async function handleAlert(payload: HandleAlertPayload | string, result?:
         }
       : payload
 
-  // 先落人员字段,再关闭告警:关闭「退养资格驳回」时后端会把人退回待提交资格,避免随后的保存把无资格写回去
-  if (data.idCard) {
-    const found = await post<PageResult<Record<string, unknown>>>('/personnel/page', {
-      pageNum: 1,
-      pageSize: 5,
-      idNumber: data.idCard,
-    })
-    const p = found?.list?.[0]
-    if (p?.id) {
-      const { townId, villageId } = await resolveScopeIds(data.town, data.village)
-      await post('/personnel', {
-        id: p.id,
-        name: data.personName || p.name,
-        idNumber: data.idCard || p.idNumber,
-        townId: townId || p.townId,
-        villageId: villageId || p.villageId,
-        bankCardNumber: data.bankCard || p.bankCardNumber,
-        bankName: data.bankName || p.bankName,
-        socialInsuranceCode: data.socialInsuranceCode || p.socialInsuranceCode,
-        heirName: data.heirName || p.heirName,
-        heirIdNumber: data.heirIdCard || p.heirIdNumber,
-        heirBankCard: data.heirBankCard || p.heirBankCard,
-        heirPhone: data.heirContact || p.heirPhone,
-        monthlyStandard: p.monthlyStandard,
-        enjoyStartMonth: p.enjoyStartMonth,
-        source: p.source,
-        remarks: p.remarks,
-      }).catch(() => undefined)
-    }
-  }
-
+  // 人员字段由页面 savePerson 一次落库;此处只关告警,避免二次无锁覆盖
   await post('/alerts/handle', {
     alertId: data.id,
     handleStatus: 'CLOSED',

+ 4 - 0
src/components.d.ts

@@ -56,14 +56,18 @@ declare module 'vue' {
     ElTag: typeof import('element-plus/es')['ElTag']
     ElTimeline: typeof import('element-plus/es')['ElTimeline']
     ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     FlowAttachments: typeof import('./components/biz/FlowAttachments.vue')['default']
     PagePanel: typeof import('./components/common/PagePanel.vue')['default']
     PersonnelDetailDescriptions: typeof import('./components/biz/PersonnelDetailDescriptions.vue')['default']
     PersonnelFormFields: typeof import('./components/biz/PersonnelFormFields.vue')['default']
+    PersonPickerDialog: typeof import('./components/biz/PersonPickerDialog.vue')['default']
     PersonStatusTimeline: typeof import('./components/biz/PersonStatusTimeline.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    SensitiveRevealSwitch: typeof import('./components/common/SensitiveRevealSwitch.vue')['default']
+    SensitiveValue: typeof import('./components/common/SensitiveValue.vue')['default']
     StatCards: typeof import('./components/common/StatCards.vue')['default']
     StatusTag: typeof import('./components/common/StatusTag.vue')['default']
     TablePagination: typeof import('./components/common/TablePagination.vue')['default']

+ 63 - 5
src/components/biz/BatchDetailDrawer.vue

@@ -116,6 +116,27 @@
             class="batch-drawer__adjust-tip"
             title="本批为年调标差额补发:不展示月标,本批只发调标差额,应发合计等于补发。"
           />
+          <div class="batch-drawer__people-search">
+            <el-input
+              v-model="peopleSearchName"
+              placeholder="姓名"
+              clearable
+              size="small"
+              style="width: 120px"
+              @keyup.enter="onPeopleSearch"
+            />
+            <el-input
+              v-model="peopleSearchIdCard"
+              placeholder="身份证(可后几位)"
+              clearable
+              size="small"
+              style="width: 160px"
+              @keyup.enter="onPeopleSearch"
+            />
+            <el-button size="small" type="primary" @click="onPeopleSearch">查询</el-button>
+            <el-button size="small" @click="resetPeopleSearch">重置</el-button>
+            <SensitiveRevealSwitch v-model="revealSensitive" />
+          </div>
           <el-table
             v-loading="peopleLoading"
             :data="pagedPeople"
@@ -127,8 +148,16 @@
             :summary-method="peopleTableSummary"
           >
             <el-table-column prop="name" label="姓名" width="80" />
-            <el-table-column label="身份证" min-width="140">
-              <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+            <el-table-column label="身份证" min-width="168">
+              <template #default="{ row }">
+                <SensitiveValue
+                  :key="`bd-id-${row.id}`"
+                  :row-key="row.id"
+                  :value="row.idCard"
+                  kind="idCard"
+                  :revealed="revealSensitive"
+                />
+              </template>
             </el-table-column>
             <el-table-column
               v-if="!isAdjustBatch"
@@ -268,13 +297,13 @@
       <el-descriptions :column="1" border size="small">
         <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
         <el-descriptions-item label="身份证">
-          {{ maskIdCard(currentPersonPay.idCard) }}
+          <SensitiveValue :value="currentPersonPay.idCard" kind="idCard" />
         </el-descriptions-item>
         <el-descriptions-item label="街镇/村居">
           {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
         </el-descriptions-item>
         <el-descriptions-item label="银行卡">
-          {{ maskBankCard(currentPersonPay.bankCard) }}
+          <SensitiveValue :value="currentPersonPay.bankCard" kind="bankCard" />
         </el-descriptions-item>
         <el-descriptions-item v-if="!isAdjustBatch" label="月补标准(元/月)">
           {{ formatAmount(currentPersonPay.monthlyAmount ?? currentPersonPay.standard) }}
@@ -343,7 +372,7 @@ import { useServerPagination } from '@/composables/useServerPagination'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { paymentSummaryFromApi, sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
-import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
 import { useUserStore } from '@/stores/user'
 import type {
   BatchFlowOverview,
@@ -351,8 +380,11 @@ import type {
   ProcessLogItem,
   VillageBatch,
 } from '@/types/biz'
+import SensitiveValue from '@/components/common/SensitiveValue.vue'
+import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
 
 const userStore = useUserStore()
+const revealSensitive = ref(false)
 
 const props = withDefaults(
   defineProps<{
@@ -399,6 +431,8 @@ const noteRawFiles = ref<File[]>([])
 const flowLoadedFor = ref('')
 const personPayVisible = ref(false)
 const currentPersonPay = ref<BatchMemberPerson | null>(null)
+const peopleSearchName = ref('')
+const peopleSearchIdCard = ref('')
 const personPayTitle = computed(() =>
   currentPersonPay.value
     ? `${currentPersonPay.value.name} · 发放详情`
@@ -427,6 +461,8 @@ const {
     parentBatchId: b.parentBatchId,
     pageNum,
     pageSize,
+    name: peopleSearchName.value.trim() || undefined,
+    idCard: peopleSearchIdCard.value.trim() || undefined,
   })
   paymentSummary.value = paymentSummaryFromApi(page.summary)
   return page
@@ -554,6 +590,18 @@ async function loadPeopleForBatch(_b: VillageBatch) {
   }
 }
 
+function onPeopleSearch() {
+  peoplePage.value = 1
+  void loadPeopleForBatch(batch.value!)
+}
+
+function resetPeopleSearch() {
+  peopleSearchName.value = ''
+  peopleSearchIdCard.value = ''
+  peoplePage.value = 1
+  if (batch.value) void loadPeopleForBatch(batch.value)
+}
+
 async function reloadFlow(force = false) {
   if (!props.batchId) return
   if (!force && flowLoadedFor.value === props.batchId && logs.value.length) return
@@ -654,6 +702,8 @@ function onClosed() {
   noteFileList.value = []
   noteRawFiles.value = []
   flowLoadedFor.value = ''
+  peopleSearchName.value = ''
+  peopleSearchIdCard.value = ''
 }
 
 watch(
@@ -741,6 +791,14 @@ watch(tab, (name) => {
     margin-bottom: 8px;
   }
 
+  &__people-search {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 8px;
+  }
+
   &__upload-btn {
     color: #fff !important;
   }

+ 235 - 0
src/components/biz/PersonPickerDialog.vue

@@ -0,0 +1,235 @@
+<template>
+  <el-dialog
+    :model-value="visible"
+    :title="title"
+    width="860px"
+    append-to-body
+    destroy-on-close
+    class="person-picker-dialog"
+    @update:model-value="onVisible"
+    @opened="onOpened"
+    @closed="onClosed"
+  >
+    <div class="person-picker-dialog__filter">
+      <el-form :inline="true" size="small" @submit.prevent="applyFilter">
+        <el-form-item label="村居">
+          <el-select
+            v-model="filter.village"
+            clearable
+            filterable
+            placeholder="全部"
+            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="filter.name" clearable placeholder="姓名" style="width: 120px" />
+        </el-form-item>
+        <el-form-item label="身份证">
+          <el-input
+            v-model="filter.idCard"
+            clearable
+            placeholder="身份证号"
+            style="width: 180px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="applyFilter">查询</el-button>
+          <el-button @click="resetFilter">重置</el-button>
+        </el-form-item>
+      </el-form>
+      <p v-if="tip" class="person-picker-dialog__tip">{{ tip }}</p>
+    </div>
+
+    <el-table
+      v-loading="loading"
+      :data="list"
+      border
+      stripe
+      size="small"
+      height="420"
+      highlight-current-row
+      @row-dblclick="onPick"
+    >
+      <el-table-column prop="name" label="姓名" width="90" />
+      <el-table-column label="身份证号" min-width="168">
+        <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+      </el-table-column>
+      <el-table-column prop="town" label="街镇" width="100" show-overflow-tooltip />
+      <el-table-column prop="village" label="村居" width="110" show-overflow-tooltip />
+      <el-table-column label="人员状态" width="110" align="center">
+        <template #default="{ row }">
+          <StatusTag :status="row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="80" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button
+            link
+            type="primary"
+            size="small"
+            :disabled="!canPick(row)"
+            @click="onPick(row)"
+          >
+            选择
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import StatusTag from '@/components/common/StatusTag.vue'
+import TablePagination from '@/components/common/TablePagination.vue'
+import { fetchPeoplePage } from '@/api/biz'
+import { useServerPagination } from '@/composables/useServerPagination'
+import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
+import { maskIdCard } from '@/utils/mask'
+import type { Person } from '@/types/biz'
+
+const props = withDefaults(
+  defineProps<{
+    visible: boolean
+    title?: string
+    tip?: string
+    /** 限定街镇(中文名) */
+    town: string
+    /** 不可选的状态文案 */
+    excludeStatuses?: string[]
+    /** 不可选的人员 ID */
+    excludeIds?: string[]
+  }>(),
+  {
+    title: '选择人员',
+    tip: '',
+    excludeStatuses: () => [],
+    excludeIds: () => [],
+  },
+)
+
+const emit = defineEmits<{
+  'update:visible': [boolean]
+  select: [Person]
+}>()
+
+const filter = reactive({
+  village: '',
+  name: '',
+  idCard: '',
+})
+const query = reactive({
+  village: '',
+  name: '',
+  idCard: '',
+})
+
+const townRef = ref(props.town)
+watch(
+  () => props.town,
+  (t) => {
+    townRef.value = t
+  },
+)
+const { villageOptions, loadVillages } = useDynamicRegionOptions(townRef)
+
+const {
+  page,
+  pageSize,
+  total,
+  list,
+  loading,
+  resetAndLoad,
+} = useServerPagination<Person>(async (pageNum, pageSize) => {
+  if (!props.town) return { list: [], total: 0 }
+  return fetchPeoplePage({
+    town: props.town,
+    village: query.village || undefined,
+    name: query.name || undefined,
+    idCard: query.idCard || undefined,
+    pageNum,
+    pageSize,
+  })
+})
+
+const excludeStatusSet = () => new Set((props.excludeStatuses || []).map(String))
+const excludeIdSet = () => new Set((props.excludeIds || []).map(String))
+
+function canPick(row: Person) {
+  if (excludeIdSet().has(String(row.id))) return false
+  const st = String(row.status || '')
+  for (const bad of excludeStatusSet()) {
+    if (!bad) continue
+    if (st === bad || st.includes(bad)) return false
+  }
+  return true
+}
+
+function onVisible(v: boolean) {
+  emit('update:visible', v)
+}
+
+async function onOpened() {
+  if (!props.town) {
+    ElMessage.warning('请先选择街镇')
+    emit('update:visible', false)
+    return
+  }
+  await loadVillages(props.town)
+  filter.village = ''
+  filter.name = ''
+  filter.idCard = ''
+  query.village = ''
+  query.name = ''
+  query.idCard = ''
+  await resetAndLoad()
+}
+
+function onClosed() {
+  filter.village = ''
+  filter.name = ''
+  filter.idCard = ''
+}
+
+function applyFilter() {
+  query.village = filter.village.trim()
+  query.name = filter.name.trim()
+  query.idCard = filter.idCard.trim()
+  void resetAndLoad()
+}
+
+function resetFilter() {
+  filter.village = ''
+  filter.name = ''
+  filter.idCard = ''
+  applyFilter()
+}
+
+function onPick(row: Person) {
+  if (!canPick(row)) {
+    ElMessage.warning('该人员当前状态不可选')
+    return
+  }
+  emit('select', row)
+  emit('update:visible', false)
+}
+</script>
+
+<style scoped lang="less">
+.person-picker-dialog {
+  &__filter {
+    margin-bottom: 8px;
+  }
+
+  &__tip {
+    margin: 0 0 8px;
+    font-size: 12px;
+    color: #8a7a66;
+    line-height: 1.5;
+  }
+}
+</style>

+ 14 - 3
src/components/biz/PersonnelDetailDescriptions.vue

@@ -43,6 +43,12 @@
           <template v-else-if="isAmount(f.key)">
             {{ formatAmount(numVal(f.key)) }}
           </template>
+          <template v-else-if="maskSensitive && (f.mask === 'idCard' || f.mask === 'bankCard')">
+            <SensitiveValue
+              :value="rawFieldText(f)"
+              :kind="f.mask === 'idCard' ? 'idCard' : 'bankCard'"
+            />
+          </template>
           <template v-else>
             {{ displayText(f) }}
           </template>
@@ -83,10 +89,11 @@ import {
   type PersonnelFieldDef,
   type PersonnelFieldGroup,
 } from '@/constants/personnelFields'
-import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
 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'
 
 const props = withDefaults(
   defineProps<{
@@ -170,6 +177,12 @@ function qualifyDisplay(raw?: string | null): string {
   return s
 }
 
+function rawFieldText(f: PersonnelFieldDef): string {
+  const raw = personField(f.key)
+  if (raw == null || raw === '') return ''
+  return String(raw)
+}
+
 function displayText(f: PersonnelFieldDef): string {
   const raw = personField(f.key)
   if (f.key === 'retireQualifyType') return qualifyDisplay(raw == null ? '' : String(raw))
@@ -177,8 +190,6 @@ function displayText(f: PersonnelFieldDef): string {
   const s = String(raw)
   if (f.key === 'bizStatus') return personnelStatusLabel(s)
   if (f.key === 'insuranceMatchResult') return labelOf('insuranceMatch', s)
-  if (props.maskSensitive && f.mask === 'idCard') return maskIdCard(s)
-  if (props.maskSensitive && f.mask === 'bankCard') return maskBankCard(s)
   return s
 }
 

+ 29 - 0
src/components/common/SensitiveRevealSwitch.vue

@@ -0,0 +1,29 @@
+<template>
+  <el-switch
+    :model-value="modelValue"
+    inline-prompt
+    :active-text="activeText"
+    :inactive-text="inactiveText"
+    @update:model-value="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    modelValue: boolean
+    activeText?: string
+    inactiveText?: string
+  }>(),
+  {
+    activeText: '显示',
+    inactiveText: '脱敏',
+  },
+)
+
+const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
+
+function onChange(v: string | number | boolean) {
+  emit('update:modelValue', Boolean(v))
+}
+</script>

+ 118 - 0
src/components/common/SensitiveValue.vue

@@ -0,0 +1,118 @@
+<template>
+  <span class="sensitive-value">
+    <span class="sensitive-value__text" :title="revealedEffective ? raw : undefined">
+      {{ display }}
+    </span>
+    <el-tooltip
+      v-if="raw"
+      :content="revealedEffective ? '隐藏' : '显示原文'"
+      placement="top"
+    >
+      <el-button
+        class="sensitive-value__btn"
+        link
+        type="primary"
+        size="small"
+        @click.stop="toggle"
+      >
+        <el-icon :size="14">
+          <Hide v-if="revealedEffective" />
+          <View v-else />
+        </el-icon>
+      </el-button>
+    </el-tooltip>
+  </span>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { Hide, View } from '@element-plus/icons-vue'
+import { maskBankCard, maskIdCard } from '@/utils/mask'
+
+const props = withDefaults(
+  defineProps<{
+    value?: string | null
+    /** idCard | bankCard | plain(plain 仅做显隐,不脱敏格式) */
+    kind?: 'idCard' | 'bankCard' | 'plain'
+    /** 受控:父级「全部显示」开关 */
+    revealed?: boolean
+    /** 行唯一键:分页复用组件时用于重置显隐,避免串行 */
+    rowKey?: string | number | null
+  }>(),
+  {
+    kind: 'plain',
+    revealed: undefined,
+    rowKey: undefined,
+  },
+)
+
+const emit = defineEmits<{ 'update:revealed': [boolean] }>()
+
+const localRevealed = ref(false)
+
+watch(
+  () => props.revealed,
+  (v) => {
+    if (v === true) localRevealed.value = true
+    if (v === false) localRevealed.value = false
+  },
+)
+
+/** 表格分页会复用单元格组件实例:换人/换值时必须清掉本行显隐状态 */
+watch(
+  () => `${props.rowKey ?? ''}|${props.kind}|${String(props.value ?? '')}`,
+  () => {
+    if (props.revealed === true) return
+    localRevealed.value = false
+  },
+)
+
+const raw = computed(() => String(props.value ?? '').trim())
+
+const revealedEffective = computed(() => {
+  if (props.revealed === true) return true
+  return localRevealed.value
+})
+
+const display = computed(() => {
+  const s = raw.value
+  if (!s) return '—'
+  if (revealedEffective.value) return s
+  if (props.kind === 'idCard') return maskIdCard(s) || '—'
+  if (props.kind === 'bankCard') return maskBankCard(s) || '—'
+  return '******'
+})
+
+function toggle() {
+  // 父级强制全部显示时,点眼睛改为关闭本行(仅本地收起需先关父级开关)
+  if (props.revealed === true) {
+    emit('update:revealed', false)
+    localRevealed.value = false
+    return
+  }
+  const next = !localRevealed.value
+  localRevealed.value = next
+  emit('update:revealed', next)
+}
+</script>
+
+<style scoped lang="less">
+.sensitive-value {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  max-width: 100%;
+}
+
+.sensitive-value__text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sensitive-value__btn {
+  padding: 0 2px;
+  height: auto;
+  flex-shrink: 0;
+}
+</style>

+ 14 - 5
src/composables/useServerPagination.ts

@@ -8,6 +8,9 @@ export type ServerPageFetcher<T> = (pageNum: number, pageSize: number) => Promis
 /**
  * 服务端分页:翻页/改 pageSize 时重新请求(不自动 immediate,由页面 resetAndLoad 触发)。
  * 同一轮同时变页码和每页条数(改 pageSize 会回到第 1 页)只请求一次。
+ *
+ * 加载策略:仅在无旧数据时盖住表格 loading;翻页/刷新保留上一屏,避免慢接口长时间白屏。
+ * 筛选重置(resetAndLoad)会清空后再加载,保证查询条件切换时仍有明确反馈。
  */
 export function useServerPagination<T>(
   fetcher: ServerPageFetcher<T>,
@@ -19,23 +22,27 @@ export function useServerPagination<T>(
   const list = ref<T[]>([]) as Ref<T[]>
   const loading = ref(false)
   let skipWatch = false
-  let loadGen = 0
+  let scheduleGen = 0
+  let fetchGen = 0
 
   async function load() {
-    loading.value = true
+    const gen = ++fetchGen
+    const blockUi = list.value.length === 0
+    if (blockUi) loading.value = true
     try {
       const res = await fetcher(page.value, pageSize.value)
+      if (gen !== fetchGen) return
       list.value = res.list || []
       total.value = Number(res.total || 0)
     } finally {
-      loading.value = false
+      if (gen === fetchGen) loading.value = false
     }
   }
 
   function scheduleLoad() {
-    const gen = ++loadGen
+    const gen = ++scheduleGen
     void nextTick(() => {
-      if (gen !== loadGen) return
+      if (gen !== scheduleGen) return
       void load()
     })
   }
@@ -43,6 +50,8 @@ export function useServerPagination<T>(
   async function resetAndLoad() {
     skipWatch = true
     page.value = 1
+    list.value = []
+    total.value = 0
     await nextTick()
     skipWatch = false
     await load()

+ 5 - 0
src/types/biz.ts

@@ -68,6 +68,10 @@ export interface Person {
   landSubsidyIncrease?: number
   createTime?: string
   updateTime?: string
+  /** 乐观锁用:后端 updateTime 毫秒戳(编辑保存时回传) */
+  updateTimeMs?: number
+  /** 备注原文(未 sanitize);保存时优先回传,避免清洗噪音永久写回 */
+  remarkRaw?: string
 }
 
 /** 批次人员行:人员底数 + 本批次发放明细金额拆分 */
@@ -284,6 +288,7 @@ export interface SpecialBiz {
   village: string
   type: string
   personName: string
+  personnelId?: string
   reason: string
   amount: number
   status: string

+ 2 - 2
src/utils/appConfig.ts

@@ -96,7 +96,7 @@ const fallbackConfig: AppConfig = {
       '/alerts',
       '/reports',
     ],
-    leader: ['/dashboard', '/approvals', '/reports'],
+    leader: ['/dashboard', '/approvals', '/reports', '/alerts'],
     agri: ['/dashboard', '/reports'],
     hr: ['/dashboard', '/reports', '/alerts'],
     admin: [
@@ -112,7 +112,7 @@ const fallbackConfig: AppConfig = {
       '/reports',
     ],
   },
-  alertReadonlyRoles: ['hr'],
+  alertReadonlyRoles: ['hr', 'leader'],
 }
 
 /** 读取全局配置(合并 window.AppConfig) */

+ 122 - 14
src/utils/request.ts

@@ -44,9 +44,14 @@ const service: AxiosInstance = axios.create({
 })
 
 /** 短时 GET 缓存:工作台/区划等只读接口,避免切换角色重复打满 */
-const getCache = new Map<string, { expireAt: number; data: unknown }>()
-const GET_CACHE_TTL_MS = 8_000
+type GetCacheEntry = { freshUntil: number; staleUntil: number; data: unknown }
+const getCache = new Map<string, GetCacheEntry>()
+/** 新鲜期内直接命中;过期后在 stale 期内先返回旧数据并后台刷新 */
+const GET_CACHE_TTL_MS = 15_000
+const GET_STALE_TTL_MS = 60_000
 const GET_CACHE_PATHS = ['/dashboard/workbench', '/dashboard/summary', '/dashboard/region-stats', '/regions']
+/** 同 key 进行中的 GET 合并,避免工作台定时刷新与切换角色叠打 */
+const getInflight = new Map<string, Promise<unknown>>()
 
 function cacheKeyOf(url: string, params?: object) {
   return url + '?' + JSON.stringify(params || {})
@@ -56,6 +61,42 @@ function shouldCacheGet(url: string) {
   return GET_CACHE_PATHS.some((p) => url === p || url.startsWith(p + '?'))
 }
 
+function pruneGetCache() {
+  const now = Date.now()
+  if (getCache.size > 80) {
+    for (const [k, v] of getCache) {
+      if (v.staleUntil <= now) getCache.delete(k)
+    }
+  }
+  if (getCache.size > 96) {
+    const victims = [...getCache.entries()]
+      .sort((a, b) => a[1].staleUntil - b[1].staleUntil)
+      .slice(0, getCache.size - 80)
+    for (const [k] of victims) getCache.delete(k)
+  }
+}
+
+function fetchAndCacheGet<T>(key: string, url: string, params?: object, config?: RequestConfig) {
+  const existing = getInflight.get(key)
+  if (existing) return existing as Promise<T>
+  const p = request<T>({ ...config, url, method: 'GET', params })
+    .then((data) => {
+      const now = Date.now()
+      getCache.set(key, {
+        freshUntil: now + GET_CACHE_TTL_MS,
+        staleUntil: now + GET_STALE_TTL_MS,
+        data,
+      })
+      pruneGetCache()
+      return data
+    })
+    .finally(() => {
+      getInflight.delete(key)
+    })
+  getInflight.set(key, p)
+  return p
+}
+
 function isWriteMethod(method?: string) {
   const m = (method || 'get').toUpperCase()
   return m === 'POST' || m === 'PUT' || m === 'PATCH' || m === 'DELETE'
@@ -123,6 +164,15 @@ function toastFail(message: string, code?: number) {
   ElMessage.error(message)
 }
 
+export class BizRequestError extends Error {
+  code: number
+  constructor(message: string, code: number) {
+    super(message)
+    this.name = 'BizRequestError'
+    this.code = code
+  }
+}
+
 function axiosErrorMessage(error: {
   code?: string
   message?: string
@@ -212,7 +262,9 @@ service.interceptors.response.use(
     if (!silent) {
       toastFail(res.message || res.msg || '请求失败', Number(res.code))
     }
-    return Promise.reject(new Error(res.message || res.msg || '请求失败'))
+    return Promise.reject(
+      new BizRequestError(res.message || res.msg || '请求失败', Number(res.code) || 0),
+    )
   },
   (error) => {
     releaseSubmitLock(error?.config)
@@ -225,6 +277,15 @@ service.interceptors.response.use(
       const code = Number(error?.response?.data?.code || error?.response?.status || 0)
       if (msg) toastFail(msg, code)
     }
+    const code = Number(error?.response?.data?.code || error?.response?.status || 0)
+    const msg =
+      error?.response?.data?.message ||
+      error?.response?.data?.msg ||
+      error?.message ||
+      '网络异常'
+    if (code) {
+      return Promise.reject(new BizRequestError(String(msg), code))
+    }
     return Promise.reject(error)
   },
 )
@@ -265,30 +326,77 @@ function request<T = unknown>(config: RequestConfig): Promise<T> {
 
 export function clearGetCache() {
   getCache.clear()
+  getInflight.clear()
+  postQueryCache.clear()
+  postQueryInflight.clear()
+}
+
+type PostQueryCacheEntry = { expireAt: number; data: unknown }
+const postQueryCache = new Map<string, PostQueryCacheEntry>()
+const postQueryInflight = new Map<string, Promise<unknown>>()
+/** 列表/分页 POST 极短缓存,减轻连点查询与短时回退同一页的等待 */
+const POST_QUERY_TTL_MS = 3_000
+
+function postQueryKey(url: string, data?: object) {
+  return 'POST:' + url + '?' + JSON.stringify(data || {})
+}
+
+function shouldCachePostQuery(url: string, config?: RequestConfig) {
+  if (config?.skipCache) return false
+  return isQueryLikeUrl(String(url || ''))
 }
 
 export function get<T = unknown>(url: string, params?: object, config?: RequestConfig) {
   if (shouldCacheGet(url) && !config?.skipCache) {
     const key = cacheKeyOf(url, params)
+    const now = Date.now()
     const hit = getCache.get(key)
-    if (hit && hit.expireAt > Date.now()) {
+    if (hit && hit.freshUntil > now) {
       return Promise.resolve(hit.data as T)
     }
-    return request<T>({ ...config, url, method: 'GET', params }).then((data) => {
-      getCache.set(key, { expireAt: Date.now() + GET_CACHE_TTL_MS, data })
-      if (getCache.size > 80) {
-        const now = Date.now()
-        for (const [k, v] of getCache) {
-          if (v.expireAt <= now) getCache.delete(k)
-        }
-      }
-      return data
-    })
+    // 过期但仍在 stale 窗:立刻返回旧数据,后台静默刷新,减少工作台长时间 loading
+    if (hit && hit.staleUntil > now) {
+      void fetchAndCacheGet(key, url, params, { ...config, silent: true }).catch(() => undefined)
+      return Promise.resolve(hit.data as T)
+    }
+    return fetchAndCacheGet<T>(key, url, params, config)
   }
   return request<T>({ ...config, url, method: 'GET', params })
 }
 
 export function post<T = unknown>(url: string, data?: object, config?: RequestConfig) {
+  if (shouldCachePostQuery(url, config)) {
+    const key = postQueryKey(url, data)
+    const now = Date.now()
+    const hit = postQueryCache.get(key)
+    if (hit && hit.expireAt > now) {
+      return Promise.resolve(hit.data as T)
+    }
+    const existing = postQueryInflight.get(key)
+    if (existing) return existing as Promise<T>
+    const p = request<T>({ ...config, url, method: 'POST', data })
+      .then((res) => {
+        postQueryCache.set(key, { expireAt: Date.now() + POST_QUERY_TTL_MS, data: res })
+        if (postQueryCache.size > 60) {
+          const t = Date.now()
+          for (const [k, v] of postQueryCache) {
+            if (v.expireAt <= t) postQueryCache.delete(k)
+          }
+        }
+        if (postQueryCache.size > 72) {
+          const victims = [...postQueryCache.entries()]
+            .sort((a, b) => a[1].expireAt - b[1].expireAt)
+            .slice(0, postQueryCache.size - 60)
+          for (const [k] of victims) postQueryCache.delete(k)
+        }
+        return res
+      })
+      .finally(() => {
+        postQueryInflight.delete(key)
+      })
+    postQueryInflight.set(key, p)
+    return p
+  }
   return request<T>({ ...config, url, method: 'POST', data })
 }
 

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

@@ -440,6 +440,10 @@ async function saveEdit() {
   if (guardBusy(saving)) return
   saving.value = true
   try {
+    if (f.id && f.updateTimeMs == null) {
+      ElMessage.warning('人员版本信息缺失,请关闭后重新打开再保存')
+      return
+    }
     await savePerson({
       ...f,
       id: f.id,
@@ -453,6 +457,13 @@ async function saveEdit() {
     ElMessage.success('人员信息已保存')
     editDlg.value = false
     await search()
+  } catch (e) {
+    const err = e as { code?: number; message?: string }
+    if (err?.code === 409 && f.id) {
+      const full = await fetchPersonDetail(String(f.id))
+      if (full) editForm.value = personToForm(full)
+      ElMessage.warning(err.message || '数据已被他人修改,已重新加载,请核对后再保存')
+    }
   } finally {
     saving.value = false
   }

+ 2 - 1
src/views/admin/RegionsAdminView.vue

@@ -178,7 +178,8 @@ async function loadTowns() {
 }
 
 async function search() {
-  loading.value = true
+  const blockUi = list.value.length === 0
+  if (blockUi) loading.value = true
   try {
     const params: { level: string; parentId?: string } = { level: q.level }
     if (q.level === 'VILLAGE' && q.parentId) params.parentId = q.parentId

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

@@ -524,8 +524,12 @@ async function onSubmitHandle() {
   saving.value = true
   try {
     const f = form.value
-    // 先落库全字段人员信息(有人员ID时)
+    // 先落库全字段人员信息(有人员ID时);版本戳必带,避免无锁覆盖
     if (f.id) {
+      if (f.updateTimeMs == null) {
+        ElMessage.warning('人员版本信息缺失,请关闭后重新打开再处理')
+        return
+      }
       await savePerson({
         ...f,
         name: f.name.trim(),

+ 339 - 54
src/views/approvals/ApprovalsView.vue

@@ -147,6 +147,8 @@
               link
               type="success"
               size="small"
+              :disabled="isRowPassBlocked(row as Approval)"
+              :title="rowPassBlockReason(row as Approval) || undefined"
               @click="onDecide(row as Approval, true)"
             >
               通过
@@ -195,14 +197,14 @@
         <el-descriptions-item label="申请人">{{ currentRow.applicant }}</el-descriptions-item>
         <el-descriptions-item label="提交时间">{{ currentRow.createdAt }}</el-descriptions-item>
         <el-descriptions-item v-if="!isSpecialApproval(currentRow) && !isQualifyApproval(currentRow)" label="总发放金额" :span="2">
-          <strong>{{ formatAmount(detailAmountSummary.payable) }}</strong>
+          <strong>{{ formatAmount(detailBatchAmountSummary.payable) }}</strong>
           <span class="muted" style="margin-left: 8px">
-            <template v-if="isAdjustDetailBatch">(调标差额批:应发=补发 {{ formatAmount(detailAmountSummary.supplement) }})</template>
+            <template v-if="isAdjustDetailBatch">(调标差额批:应发=补发 {{ formatAmount(detailBatchAmountSummary.supplement) }})</template>
             <template v-else>
-              (标准 {{ formatAmount(detailAmountSummary.monthly) }}
-              + 补发 {{ formatAmount(detailAmountSummary.supplement) }}
-              + 丧葬 {{ formatAmount(detailAmountSummary.funeral) }}
-              - 扣减 {{ formatAmount(detailAmountSummary.deduct) }})
+              (标准 {{ formatAmount(detailBatchAmountSummary.monthly) }}
+              + 补发 {{ formatAmount(detailBatchAmountSummary.supplement) }}
+              + 丧葬 {{ formatAmount(detailBatchAmountSummary.funeral) }}
+              - 扣减 {{ formatAmount(detailBatchAmountSummary.deduct) }})
             </template>
           </span>
         </el-descriptions-item>
@@ -242,6 +244,7 @@
             {{ isQualifyApproval(currentRow) ? '申请人员' : '涉及人员' }}
             <em>共 {{ detailPeople.length || (currentRow?.peopleCount ?? 0) }} 人</em>
           </div>
+          <SensitiveRevealSwitch v-model="revealSensitive" />
         </div>
         <el-table
           v-loading="peopleLoading"
@@ -252,8 +255,16 @@
           max-height="240"
         >
           <el-table-column prop="name" label="姓名" width="90" />
-          <el-table-column label="身份证" min-width="160">
-            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          <el-table-column label="身份证" min-width="168">
+            <template #default="{ row }">
+              <SensitiveValue
+                :key="`sp-id-${row.id}`"
+                :row-key="row.id"
+                :value="row.idCard"
+                kind="idCard"
+                :revealed="revealSensitive"
+              />
+            </template>
           </el-table-column>
           <el-table-column prop="town" label="街镇" width="90" />
           <el-table-column prop="village" label="村居" width="90" />
@@ -286,21 +297,73 @@
       </div>
 
       <div v-else class="approvals-view__people">
+        <el-alert
+          v-if="detailPassBlockAlert"
+          :title="detailPassBlockAlert"
+          type="warning"
+          show-icon
+          :closable="false"
+          class="approvals-view__block-alert"
+        />
         <div class="approvals-view__people-toolbar">
           <div class="approvals-view__people-title">
             {{ isTownReview ? '村合作社提交名单' : '本镇人员列表' }}
             <span v-if="currentRow?.village">
               ({{ currentRow.town }}{{ currentRow.village }} · 批次 {{ currentRow.batchNo }})
             </span>
-            <em>共 {{ detailPeople.length }} 人</em>
+            <em>共 {{ detailPeopleTotal || detailPeople.length }} 人</em>
             <em class="approvals-view__people-amount">
-              ,总发放 {{ formatAmount(detailAmountSummary.payable) }} 元
+              ,总发放 {{ formatAmount(detailBatchAmountSummary.payable) }} 元
             </em>
-            <em v-if="detailStatusFilter">,当前 {{ filteredDetailPeople.length }} 人</em>
-            <em v-if="isTownReview && pendingQualifyCount > 0" class="approvals-view__people-warn">
-              待核验 {{ pendingQualifyCount }} 人,须核验后才能通过
+            <em v-if="detailStatusFilter || detailSearchName || detailSearchIdCard">
+              ,当前筛选 {{ detailPeopleTotal }} 人
             </em>
           </div>
+          <div class="approvals-view__people-search">
+            <el-input
+              v-model="detailSearchName"
+              placeholder="姓名"
+              clearable
+              size="small"
+              style="width: 120px"
+              @keyup.enter="onDetailSearch"
+            />
+            <el-input
+              v-model="detailSearchIdCard"
+              placeholder="身份证(可后几位)"
+              clearable
+              size="small"
+              style="width: 160px"
+              @keyup.enter="onDetailSearch"
+            />
+            <el-select
+              v-model="detailAmountSort"
+              clearable
+              placeholder="金额排序"
+              size="small"
+              style="width: 140px"
+              @change="onDetailSearch"
+            >
+              <el-option label="总计金额↓" value="totalAmount:desc" />
+              <el-option label="总计金额↑" value="totalAmount:asc" />
+              <el-option label="补发金额↓" value="supplementAmount:desc" />
+              <el-option label="丧葬金额↓" value="funeralAmount:desc" />
+            </el-select>
+            <el-select
+              v-model="detailAmountFilter"
+              clearable
+              placeholder="金额筛选"
+              size="small"
+              style="width: 120px"
+              @change="onDetailSearch"
+            >
+              <el-option label="仅有补发" value="supplement" />
+              <el-option label="仅有丧葬" value="funeral" />
+            </el-select>
+            <el-button size="small" type="primary" @click="onDetailSearch">查询</el-button>
+            <el-button size="small" @click="resetDetailSearch">重置</el-button>
+            <SensitiveRevealSwitch v-model="revealSensitive" />
+          </div>
           <el-select
             v-model="detailStatusFilter"
             clearable
@@ -327,6 +390,7 @@
           max-height="320"
           show-summary
           :summary-method="detailTableSummary"
+          :row-class-name="detailPeopleRowClass"
         >
           <el-table-column prop="name" label="姓名" width="110">
             <template #default="{ row }">
@@ -351,11 +415,27 @@
               </el-tag>
             </template>
           </el-table-column>
-          <el-table-column label="身份证" min-width="140">
-            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          <el-table-column label="身份证" min-width="168">
+            <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 label="银行卡" min-width="140">
-            <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
+          <el-table-column label="银行卡" min-width="160">
+            <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="!isAdjustDetailBatch" label="标准金额" width="100" align="right">
             <template #default="{ row }">
@@ -467,8 +547,8 @@
         <el-button
           type="primary"
           :loading="deciding"
-          :disabled="townPassBlocked"
-          :title="townPassBlockedReason || undefined"
+          :disabled="detailPassBlocked"
+          :title="detailPassBlockedReason || undefined"
           @click="onDecide(currentRow!, true)"
         >
           通过
@@ -545,7 +625,7 @@
     >
       <p class="approvals-view__reject-tip">
         <template v-if="isQualifyRejectTarget">
-          「无资格」将人员标为异常待处理;「补材料」退回村合作社「待提交资格」后可重提
+          驳回后退回村合作社「待提交资格」,补充材料后可重新提交
         </template>
         <template v-else-if="isTownReview && ineligibleMembers.length">
           本批有 {{ ineligibleMembers.length }} 人已认定「无资格」({{
@@ -563,12 +643,6 @@
         </template>
       </p>
       <el-form label-width="90px" size="small">
-        <el-form-item v-if="isQualifyRejectTarget" label="驳回方式" required>
-          <el-radio-group v-model="rejectQualifyMode">
-            <el-radio value="supplement">驳回补材料</el-radio>
-            <el-radio value="ineligible">认定无资格</el-radio>
-          </el-radio-group>
-        </el-form-item>
         <el-form-item label="驳回理由" required>
           <el-input
             v-model="rejectOpinion"
@@ -610,7 +684,9 @@
       </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>
-        <el-descriptions-item label="身份证">{{ maskIdCard(qualifyTarget.idCard) }}</el-descriptions-item>
+        <el-descriptions-item label="身份证">
+          <SensitiveValue :value="qualifyTarget.idCard" kind="idCard" />
+        </el-descriptions-item>
         <el-descriptions-item label="村居">
           {{ qualifyTarget.town }}{{ qualifyTarget.village }}
         </el-descriptions-item>
@@ -670,6 +746,7 @@ import {
   fetchBatchMemberPeoplePage,
   fetchPersonDetail,
   fetchSpecialBizPerson,
+  fetchUnresolvedInsuranceCount,
   reviewPersonQualify,
   uploadBizFile,
 } from '@/api/biz'
@@ -678,13 +755,21 @@ import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { guardBusy } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
-import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
+import {
+  paymentSummaryFromApi,
+  sumBatchMemberAmounts,
+  type BatchAmountSummary,
+} from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
-import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
 import type { Approval, BatchMemberPerson, Person } from '@/types/biz'
+import SensitiveValue from '@/components/common/SensitiveValue.vue'
+import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
+/** 审批详情证件/卡号脱敏开关 */
+const revealSensitive = ref(false)
 const {
   filterTown,
   filterVillage,
@@ -705,6 +790,10 @@ const detailVisible = ref(false)
 const currentRow = ref<Approval | null>(null)
 const detailPeople = ref<BatchMemberPerson[]>([])
 const peopleLoading = ref(false)
+/** 发放明细分页接口回填的全量合计(不是本页合计) */
+const detailPaymentSummary = ref<BatchAmountSummary | null>(null)
+/** 分管领导:批次居保未闭环人数缓存(approvalId → count) */
+const unresolvedInsuranceByApproval = ref<Record<string, number>>({})
 
 const isAdjustDetailBatch = computed(() =>
   isYearlyAdjustBatchLike({
@@ -713,16 +802,35 @@ const isAdjustDetailBatch = computed(() =>
   }),
 )
 
-const detailAmountSummary = computed(() =>
+/** 表格底部:仅本页合计 */
+const detailPageAmountSummary = computed(() =>
   sumBatchMemberAmounts(detailPeople.value, { adjustOnly: isAdjustDetailBatch.value }),
 )
 
+/** 详情头/工具栏:全量合计(接口 summary → 列表行金额 → 本页兜底) */
+const detailBatchAmountSummary = computed((): BatchAmountSummary => {
+  if (detailPaymentSummary.value) return detailPaymentSummary.value
+  const row = currentRow.value
+  if (row && (row.totalAmount != null || row.peopleCount != null)) {
+    return {
+      count: Number(row.peopleCount || 0),
+      monthly: 0,
+      oneTime: 0,
+      supplement: 0,
+      funeral: 0,
+      deduct: 0,
+      payable: Number(row.totalAmount || 0),
+    }
+  }
+  return detailPageAmountSummary.value
+})
+
 function detailTableSummary(param: {
   columns: Array<{ label?: string; property?: string }>
 }) {
-  const sum = detailAmountSummary.value
+  const sum = detailPageAmountSummary.value
   return param.columns.map((col, index) => {
-    if (index === 0) return '合计'
+    if (index === 0) return '本页合计'
     const label = String(col.label || col.property || '')
     if (label.includes('标准')) return formatAmount(sum.monthly)
     if (label.includes('补发')) return formatAmount(sum.supplement)
@@ -784,6 +892,7 @@ function onRejectDialogClosed() {
   rejectRawFiles.value = []
   rejectTarget.value = null
   rejectBatchMode.value = false
+  rejectQualifyMode.value = 'supplement'
 }
 
 function onPassDialogClosed() {
@@ -863,6 +972,12 @@ async function syncScopeTown() {
   }
 }
 const detailStatusFilter = ref('')
+const detailSearchName = ref('')
+const detailSearchIdCard = ref('')
+/** totalAmount:desc | supplementAmount:desc | funeralAmount:desc ... */
+const detailAmountSort = ref('')
+/** supplement | funeral */
+const detailAmountFilter = ref('')
 const detailStatusOptions = computed(() => {
   const set = new Set<string>()
   for (const p of detailPeople.value) {
@@ -882,6 +997,20 @@ function resetDetailPeoplePage() {
   detailPeoplePage.value = 1
 }
 
+function onDetailSearch() {
+  detailPeoplePage.value = 1
+  void reloadDetailPeople()
+}
+
+function resetDetailSearch() {
+  detailSearchName.value = ''
+  detailSearchIdCard.value = ''
+  detailAmountSort.value = ''
+  detailAmountFilter.value = ''
+  detailPeoplePage.value = 1
+  void reloadDetailPeople()
+}
+
 /** 按角色限定审批节点 */
 const approvalNode = computed(() => {
   if (userStore.currentRole === 'town') return '镇经发中心初审'
@@ -903,7 +1032,7 @@ const {
   selectedIds.value = []
   const role = userStore.roleInfo
   const isPending = activeTab.value === 'pending'
-  return fetchApprovals({
+  const page = await fetchApprovals({
     tab: isPending ? 'pending' : 'history',
     node: approvalNode.value,
     town: isTownReview.value || isLeaderReview.value ? role?.town : filterTown.value || undefined,
@@ -913,6 +1042,8 @@ const {
     pageNum,
     pageSize,
   })
+  void refreshUnresolvedInsuranceForList(page.list || [])
+  return page
 })
 const list = pagedList
 
@@ -926,28 +1057,102 @@ const pageTitle = computed(() => {
 /** 当前发放周期对应批次号 */
 const currentBatchNo = computed(() => userStore.currentMonth.replace('-', ''))
 
-/** 详情中仍需资格核验的人数(退养资格字段为空) */
+/** 详情中仍需资格核验的人数(仅镇经发场景使用) */
 const pendingQualifyCount = computed(
   () => detailPeople.value.filter((p) => needsQualifyReview(p)).length,
 )
 
-const townPassBlockedReason = computed(() => {
-  if (isQualifyApproval(currentRow.value)) return ''
-  if (hasIneligibleMembers.value) {
-    return `存在「无资格」人员 ${ineligibleMembers.value.length} 人,只能驳回由村处理`
+/** 已认定「无资格」的人员:镇经发批次只能驳回 */
+const ineligibleMembers = computed(() =>
+  detailPeople.value.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格'),
+)
+const hasIneligibleMembers = computed(() => ineligibleMembers.value.length > 0)
+
+/**
+ * 详情弹窗「通过」禁用原因:
+ * - 镇经发:无资格 / 未核验资格(资格审批本身除外)
+ * - 分管领导:居保未闭环(资格核验与领导无关,绝不拦)
+ */
+const detailPassBlockedReason = computed(() => {
+  const row = currentRow.value
+  if (!row || isQualifyApproval(row) || isSpecialApproval(row)) return ''
+  if (isTownReview.value) {
+    if (hasIneligibleMembers.value) {
+      return `存在「无资格」人员 ${ineligibleMembers.value.length} 人,只能驳回由村处理`
+    }
+    if (pendingQualifyCount.value > 0) {
+      return `有 ${pendingQualifyCount.value} 人退养资格尚未核验,请先在名单中点「核验资格」`
+    }
+    return ''
   }
-  if (pendingQualifyCount.value > 0) {
-    return `有 ${pendingQualifyCount.value} 人退养资格尚未核验,请先点「核验资格」`
+  if (isLeaderReview.value) {
+    const n = unresolvedInsuranceByApproval.value[String(row.id)] || 0
+    if (n > 0) {
+      return `仍有 ${n} 人居保未匹配/未通过,请先完成居保匹配或人工确认后再通过`
+    }
   }
   return ''
 })
-const townPassBlocked = computed(() => Boolean(townPassBlockedReason.value))
+const detailPassBlocked = computed(() => Boolean(detailPassBlockedReason.value))
 
-/** 已认定「无资格」的人员:批次只能驳回 */
-const ineligibleMembers = computed(() =>
-  detailPeople.value.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格'),
-)
-const hasIneligibleMembers = computed(() => ineligibleMembers.value.length > 0)
+const detailPassBlockAlert = computed(() => {
+  if (!detailPassBlockedReason.value) return ''
+  if (isTownReview.value && pendingQualifyCount.value > 0) {
+    return `${detailPassBlockedReason.value}(当前页可见待核验人员已标红,请先处理)`
+  }
+  return detailPassBlockedReason.value
+})
+
+function rowPassBlockReason(row: Approval): string {
+  if (row.status !== '待审批') return ''
+  if (isQualifyApproval(row) || isSpecialApproval(row)) return ''
+  if (isTownReview.value) {
+    // 详情已打开且是同一条:与弹窗共用资格拦截
+    if (detailVisible.value && currentRow.value?.id === row.id) {
+      return detailPassBlockedReason.value
+    }
+    return ''
+  }
+  if (isLeaderReview.value) {
+    const n = unresolvedInsuranceByApproval.value[String(row.id)] || 0
+    if (n > 0) {
+      return `仍有 ${n} 人居保未匹配/未通过,不可通过`
+    }
+  }
+  return ''
+}
+
+function isRowPassBlocked(row: Approval) {
+  return Boolean(rowPassBlockReason(row))
+}
+
+function detailPeopleRowClass({ row }: { row: BatchMemberPerson }) {
+  if (isTownReview.value && needsQualifyReview(row)) {
+    return 'approvals-view__row--qualify-pending'
+  }
+  if (isTownReview.value && (row.retireQualifyType === '无资格' || row.status === '无资格')) {
+    return 'approvals-view__row--ineligible'
+  }
+  return ''
+}
+
+async function refreshUnresolvedInsuranceForList(rows: Approval[]) {
+  if (!isLeaderReview.value) return
+  const next: Record<string, number> = { ...unresolvedInsuranceByApproval.value }
+  await Promise.all(
+    rows.map(async (row) => {
+      if (isQualifyApproval(row) || isSpecialApproval(row)) return
+      const batchId = row.batchId || batchIdOfApproval(row)
+      if (!batchId) return
+      try {
+        next[String(row.id)] = await fetchUnresolvedInsuranceCount(batchId)
+      } catch {
+        // 忽略单条失败,不挡列表
+      }
+    }),
+  )
+  unresolvedInsuranceByApproval.value = next
+}
 
 function onSelectionChange(rows: Approval[]) {
   selectedIds.value = rows.map((r) => r.id)
@@ -1046,7 +1251,10 @@ async function openDetail(row: Approval) {
   currentRow.value = { ...row }
   detailVisible.value = true
   detailPeople.value = []
+  detailPaymentSummary.value = null
   detailStatusFilter.value = ''
+  detailSearchName.value = ''
+  detailSearchIdCard.value = ''
   peopleLoading.value = true
   try {
     if (isQualifyApproval(row) || isSpecialApproval(row)) {
@@ -1103,6 +1311,17 @@ async function openDetail(row: Approval) {
       detailPeople.value = []
       return
     }
+    if (isLeaderReview.value) {
+      try {
+        const n = await fetchUnresolvedInsuranceCount(batchId)
+        unresolvedInsuranceByApproval.value = {
+          ...unresolvedInsuranceByApproval.value,
+          [String(row.id)]: n,
+        }
+      } catch {
+        // ignore
+      }
+    }
     const peoplePage = await fetchBatchMemberPeoplePage(batchId, {
       batchNo: row.batchNo,
       town: row.town,
@@ -1110,21 +1329,22 @@ async function openDetail(row: Approval) {
       pruneDefaults: false,
       pageNum: 1,
       pageSize: detailPeoplePageSize.value,
+      name: detailSearchName.value.trim() || undefined,
+      idCard: detailSearchIdCard.value.trim() || undefined,
     })
     detailPeople.value = peoplePage.list
     detailPeopleTotal.value = peoplePage.total
     detailPeoplePage.value = 1
-    const sum = sumBatchMemberAmounts(detailPeople.value, {
-      adjustOnly: isYearlyAdjustBatchLike({
-        batchNo: row.batchNo,
-        remarks: (row as any).remarks || (row as any).remark,
-      }),
-    })
+    const apiSum = paymentSummaryFromApi(peoplePage.summary)
+    detailPaymentSummary.value = apiSum
     if (currentRow.value) {
       currentRow.value = {
         ...currentRow.value,
-        peopleCount: peoplePage.total || currentRow.value.peopleCount,
-        totalAmount: currentRow.value.totalAmount ?? sum.payable,
+        peopleCount: apiSum?.count || peoplePage.total || currentRow.value.peopleCount,
+        totalAmount:
+          apiSum?.payable ??
+          currentRow.value.totalAmount ??
+          0,
       }
     }
   } finally {
@@ -1135,7 +1355,10 @@ async function openDetail(row: Approval) {
 function onDetailClosed() {
   currentRow.value = null
   detailPeople.value = []
+  detailPaymentSummary.value = null
   detailStatusFilter.value = ''
+  detailSearchName.value = ''
+  detailSearchIdCard.value = ''
 }
 
 async function doDecide(
@@ -1190,6 +1413,28 @@ async function doDecide(
 
 async function townPassBlockReason(row: Approval, people?: Person[]): Promise<string | null> {
   if (isSpecialApproval(row) || isQualifyApproval(row)) return null
+  // 分管领导:只拦居保未闭环;资格核验不属于领导职责
+  if (isLeaderReview.value || row.node.includes('分管领导')) {
+    const cached = unresolvedInsuranceByApproval.value[String(row.id)]
+    if (cached != null && cached > 0) {
+      return `仍有 ${cached} 人居保未匹配/未通过,请先完成居保匹配或人工确认后再通过`
+    }
+    const batchId = row.batchId || batchIdOfApproval(row)
+    if (!batchId) return null
+    try {
+      const n = await fetchUnresolvedInsuranceCount(batchId)
+      unresolvedInsuranceByApproval.value = {
+        ...unresolvedInsuranceByApproval.value,
+        [String(row.id)]: n,
+      }
+      if (n > 0) {
+        return `仍有 ${n} 人居保未匹配/未通过,请先完成居保匹配或人工确认后再通过`
+      }
+    } catch {
+      // ignore
+    }
+    return null
+  }
   if (!(isTownReview.value || row.node.includes('镇经发'))) return null
   let list = people
   if (!list) {
@@ -1248,6 +1493,7 @@ async function onDecide(row: Approval, pass: boolean) {
   }
   rejectTarget.value = row
   rejectBatchMode.value = false
+  rejectQualifyMode.value = 'supplement'
   rejectFileList.value = []
   rejectRawFiles.value = []
   rejectOpinion.value = hasIneligibleMembers.value
@@ -1333,6 +1579,7 @@ async function onBatchDecide(pass: boolean) {
   if (!pass) {
     rejectBatchMode.value = true
     rejectTarget.value = null
+    rejectQualifyMode.value = 'supplement'
     rejectOpinion.value = ''
     rejectFileList.value = []
     rejectRawFiles.value = []
@@ -1373,9 +1620,26 @@ async function reloadDetailPeople() {
       pruneDefaults: false,
       pageNum: detailPeoplePage.value,
       pageSize: detailPeoplePageSize.value,
+      name: detailSearchName.value.trim() || undefined,
+      idCard: detailSearchIdCard.value.trim() || undefined,
+      sortBy: detailAmountSort.value ? detailAmountSort.value.split(':')[0] : undefined,
+      sortOrder: detailAmountSort.value ? detailAmountSort.value.split(':')[1] : undefined,
+      onlyFuneral: detailAmountFilter.value === 'funeral' || undefined,
+      onlySupplement: detailAmountFilter.value === 'supplement' || undefined,
     })
     detailPeople.value = peoplePage.list
     detailPeopleTotal.value = peoplePage.total
+    const apiSum = paymentSummaryFromApi(peoplePage.summary)
+    if (apiSum) {
+      detailPaymentSummary.value = apiSum
+      if (currentRow.value) {
+        currentRow.value = {
+          ...currentRow.value,
+          peopleCount: apiSum.count || peoplePage.total || currentRow.value.peopleCount,
+          totalAmount: apiSum.payable,
+        }
+      }
+    }
   } finally {
     peopleLoading.value = false
   }
@@ -1429,14 +1693,35 @@ watch(
 .approvals-view__people {
   margin-top: 16px;
 
+  .approvals-view__block-alert {
+    margin-bottom: 10px;
+  }
+
+  :deep(.approvals-view__row--qualify-pending) {
+    --el-table-tr-bg-color: #fff7e6;
+  }
+
+  :deep(.approvals-view__row--ineligible) {
+    --el-table-tr-bg-color: #fff1f0;
+  }
+
   &-toolbar {
     display: flex;
+    flex-wrap: wrap;
     align-items: center;
     justify-content: space-between;
     gap: 12px;
     margin-bottom: 8px;
   }
 
+  &-search {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+  }
+
   &-title {
     flex: 1;
     min-width: 0;

+ 51 - 11
src/views/base/BaseInfoView.vue

@@ -165,6 +165,7 @@
         <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-form-item>
       </el-form>
 
@@ -180,16 +181,32 @@
         >
           <el-table-column type="selection" width="42" :selectable="canSubmitQualify" />
           <el-table-column prop="name" label="姓名" width="80" />
-          <el-table-column label="身份证" min-width="148">
-            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          <el-table-column label="身份证" min-width="168">
+            <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 label="手机号" width="118">
             <template #default="{ row }">{{ row.phoneNumber || '—' }}</template>
           </el-table-column>
           <el-table-column prop="town" label="街镇" width="90" />
           <el-table-column prop="village" label="村居" width="90" />
-          <el-table-column label="银行卡" min-width="140">
-            <template #default="{ row }">{{ maskBankCard(row.bankCard) }}</template>
+          <el-table-column label="银行卡" min-width="160">
+            <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 label="开户行" min-width="100" show-overflow-tooltip>
             <template #default="{ row }">{{ row.bankName || '—' }}</template>
@@ -407,13 +424,13 @@
           </el-descriptions-item>
           <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
           <el-descriptions-item label="身份证">
-            {{ maskIdCard(currentPersonPay.idCard) }}
+            <SensitiveValue :value="currentPersonPay.idCard" kind="idCard" />
           </el-descriptions-item>
           <el-descriptions-item label="街镇/村居">
             {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
           </el-descriptions-item>
           <el-descriptions-item label="银行卡">
-            {{ maskBankCard(currentPersonPay.bankCard) }}
+            <SensitiveValue :value="currentPersonPay.bankCard" kind="bankCard" />
           </el-descriptions-item>
           <el-descriptions-item label="开户行">
             {{ currentPersonPay.bankName || '—' }}
@@ -603,7 +620,7 @@ import { guardBusy } from '@/composables/useSubmitLock'
 import { useServerPagination } from '@/composables/useServerPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
-import { maskIdCard, maskBankCard, formatAmount } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
 import { retireQualifyLabel } from '@/utils/statusLabels'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import {
@@ -617,9 +634,13 @@ import type {
   BatchMemberPerson,
   Person,
 } from '@/types/biz'
+import SensitiveValue from '@/components/common/SensitiveValue.vue'
+import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
 
 const PEOPLE_PAGE_SIZE = 20
 const peoplePageSizes = [10, 20, 50]
+/** 列表证件/卡号:默认脱敏,可开关显示原文 */
+const revealSensitive = ref(false)
 
 const userStore = useUserStore()
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
@@ -1026,7 +1047,24 @@ const personFormRules: FormRules = {
       trigger: 'blur',
     },
   ],
-  bankCard: [{ required: true, message: '请输入银行卡号', trigger: 'blur' }],
+  bankCard: [
+    { required: true, message: '请输入银行卡号', trigger: 'blur' },
+    {
+      validator: (_r, v, cb) => {
+        const s = String(v || '').trim().replace(/\s/g, '')
+        if (!s) {
+          cb(new Error('请输入银行卡号'))
+          return
+        }
+        if (!/^\d{12,22}$/.test(s)) {
+          cb(new Error('银行卡号应为 12–22 位数字'))
+          return
+        }
+        cb()
+      },
+      trigger: 'blur',
+    },
+  ],
   heirIdNumber: [
     {
       validator: (_r, v, cb) => {
@@ -1177,7 +1215,7 @@ async function onSavePerson() {
   if (guardBusy(saving)) return
   saving.value = true
   try {
-    await savePerson({
+    const saved = await savePerson({
       ...f,
       id: undefined,
       town,
@@ -1196,9 +1234,11 @@ async function onSavePerson() {
       remark: (f.remark || '').trim(),
       source: f.source || '村级录入',
     })
-    ElMessage.success('人员保存成功')
+    ElMessage.success(saved?.name ? `「${saved.name}」保存成功,状态为待提交资格` : '人员保存成功')
     personDialogVisible.value = false
-    await loadPeople()
+    rosterFilter.bizStatus = 'DRAFT'
+    rosterFilter.source = ''
+    await peoplePager.resetAndLoad()
   } finally {
     saving.value = false
   }

+ 12 - 9
src/views/dashboard/DashboardView.vue

@@ -520,7 +520,7 @@ const router = useRouter()
 const tickerPaused = ref(false)
 let refreshTimer: ReturnType<typeof setInterval> | null = null
 /** 指标/告警自动刷新间隔(可研:实时刷新) */
-const DASHBOARD_REFRESH_MS = 30_000
+const DASHBOARD_REFRESH_MS = 120_000
 
 /** 图表主色:政务蓝行政台 */
 const CHART_COLOR = '#2B73F8'
@@ -578,8 +578,8 @@ const roleTip = computed(() => {
   if (role.key === 'agri') return '全区土地退养资格只读监测'
   if (role.key === 'hr') return '全区发放与居保只读监测(告警不可关闭)'
   if (role.allData) return '全区数据视角'
-  if (role.village) return '本村人员录入与提交资格(不看见批次)'
-  if (role.key === 'town') return '本镇按人审退养资格(不看见批次)'
+  if (role.village) return '本村人员录入与提交资格'
+  if (role.key === 'town') return '本镇按人审退养资格'
   if (role.key === 'social') return '本镇居保、清单、特殊业务与出盘回盘'
   if (role.key === 'leader') return '审本镇月度清单与特殊业务'
   if (role.town) return '本镇数据视角'
@@ -1222,9 +1222,11 @@ function onMonthChange(month: string | null) {
 }
 
 /** 按当前角色与发放周期加载工作台(防抖 + 竞态序号) */
-async function loadData() {
+async function loadData(opts?: { forceLoading?: boolean }) {
   const seq = ++loadSeq
-  loading.value = true
+  // 仅首屏或切换角色/月份时盖 loading;定时静默刷新保留旧数据,避免整页长时间转圈
+  const blockUi = opts?.forceLoading || !data.value
+  if (blockUi) loading.value = true
   try {
     const next = await fetchDashboard({
       roleKey: userStore.currentRole,
@@ -1237,16 +1239,16 @@ async function loadData() {
   }
 }
 
-function scheduleLoad() {
+function scheduleLoad(forceLoading = false) {
   if (loadTimer) clearTimeout(loadTimer)
   loadTimer = setTimeout(() => {
     loadTimer = null
-    void loadData()
+    void loadData({ forceLoading })
   }, 80)
 }
 
 onMounted(() => {
-  void loadData()
+  void loadData({ forceLoading: true })
   refreshTimer = setInterval(() => {
     void loadData()
   }, DASHBOARD_REFRESH_MS)
@@ -1264,7 +1266,8 @@ onUnmounted(() => {
 watch(
   () => [userStore.currentRole, userStore.currentMonth],
   () => {
-    scheduleLoad()
+    data.value = null
+    scheduleLoad(true)
   },
 )
 </script>

+ 54 - 8
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">
@@ -118,14 +118,20 @@
               link
               type="success"
               size="small"
-              :disabled="pendingSpecialCount > 0"
+              :disabled="!canSubmitLeader(row as VillageMonthlyBatch)"
               :loading="actingId === row.id"
               @click="onSubmitLeader(row as VillageMonthlyBatch)"
             >
               提交领导
             </el-button>
             <span
-              v-if="isCurrentBatch(row as VillageMonthlyBatch) && row.status === '待提交' && pendingSpecialCount > 0"
+              v-if="isCurrentBatch(row as VillageMonthlyBatch) && row.status === '待提交' && unresolvedInsuranceOf(row as VillageMonthlyBatch) > 0"
+              class="muted"
+            >
+              尚有 {{ unresolvedInsuranceOf(row as VillageMonthlyBatch) }} 人居保未匹配/未通过
+            </span>
+            <span
+              v-else-if="isCurrentBatch(row as VillageMonthlyBatch) && row.status === '待提交' && pendingSpecialCount > 0"
               class="muted"
             >
               本镇尚有 {{ pendingSpecialCount }} 笔特殊业务待审
@@ -220,6 +226,7 @@
         </el-descriptions>
         <p class="monthly-payment-view__sub-tip">
           总发放按本批发放明细实时汇总;含停止后剔除、补发/丧葬等变动。分列见下表或点「详情」。
+          <SensitiveRevealSwitch v-model="revealSensitive" style="margin-left: 12px" />
         </p>
         <el-table
           v-loading="detailLoading"
@@ -254,8 +261,16 @@
               </el-tag>
             </template>
           </el-table-column>
-          <el-table-column label="身份证" min-width="150">
-            <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
+          <el-table-column label="身份证" min-width="168">
+            <template #default="{ row }">
+              <SensitiveValue
+                :key="`m-id-${row.id}`"
+                :row-key="row.id"
+                :value="row.idCard"
+                kind="idCard"
+                :revealed="revealSensitive"
+              />
+            </template>
           </el-table-column>
           <el-table-column v-if="!isAdjustDetailBatch" label="标准金额" width="100" align="right">
             <template #default="{ row }">
@@ -320,13 +335,13 @@
         <el-descriptions :column="1" border size="small">
           <el-descriptions-item label="姓名">{{ currentPersonPay.name }}</el-descriptions-item>
           <el-descriptions-item label="身份证">
-            {{ maskIdCard(currentPersonPay.idCard) }}
+            <SensitiveValue :value="currentPersonPay.idCard" kind="idCard" />
           </el-descriptions-item>
           <el-descriptions-item label="街镇/村居">
             {{ currentPersonPay.town }} / {{ currentPersonPay.village }}
           </el-descriptions-item>
           <el-descriptions-item label="银行卡">
-            {{ maskBankCard(currentPersonPay.bankCard) }}
+            <SensitiveValue :value="currentPersonPay.bankCard" kind="bankCard" />
           </el-descriptions-item>
           <el-descriptions-item label="开户行">
             {{ currentPersonPay.bankName || '—' }}
@@ -402,6 +417,7 @@ import {
   fetchBatchMemberPeoplePage,
   fetchVillageMonthlyBatchesPage,
   fetchPendingSpecialCount,
+  fetchUnresolvedInsuranceCount,
   issueVillageMonthlyDisk,
   listCalcRounds,
   lockCalc,
@@ -414,7 +430,9 @@ import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { paymentSummaryFromApi, sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
-import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
+import { formatAmount } from '@/utils/mask'
+import SensitiveValue from '@/components/common/SensitiveValue.vue'
+import SensitiveRevealSwitch from '@/components/common/SensitiveRevealSwitch.vue'
 import type { BatchMemberPerson, VillageMonthlyBatch } from '@/types/biz'
 
 const userStore = useUserStore()
@@ -430,8 +448,11 @@ const {
 
 const actingId = ref('')
 const pendingSpecialCount = ref(0)
+/** 待提交批次:居保未闭环人数(按批次 id) */
+const unresolvedInsuranceMap = ref<Record<string, number>>({})
 const detailVisible = ref(false)
 const detailLoading = ref(false)
+const revealSensitive = ref(false)
 const currentRow = ref<VillageMonthlyBatch | null>(null)
 const detailPeople = ref<BatchMemberPerson[]>([])
 const detailPaymentSummary = ref<ReturnType<typeof paymentSummaryFromApi>>(null)
@@ -574,6 +595,14 @@ function isCurrentBatch(row: VillageMonthlyBatch) {
   return Boolean(currentBatchNo.value) && row.batchNo === currentBatchNo.value
 }
 
+function unresolvedInsuranceOf(row: VillageMonthlyBatch) {
+  return Number(unresolvedInsuranceMap.value[row.id] || 0)
+}
+
+function canSubmitLeader(row: VillageMonthlyBatch) {
+  return pendingSpecialCount.value <= 0 && unresolvedInsuranceOf(row) <= 0
+}
+
 watch(pagedDetailPeople, (rows) => {
   detailPeople.value = rows
 })
@@ -597,6 +626,14 @@ function onDetailClosed() {
 }
 
 async function onSubmitLeader(row: VillageMonthlyBatch) {
+  const pendingIns = await fetchUnresolvedInsuranceCount(row.id).catch(
+    () => unresolvedInsuranceOf(row),
+  )
+  unresolvedInsuranceMap.value = { ...unresolvedInsuranceMap.value, [row.id]: pendingIns }
+  if (pendingIns > 0) {
+    ElMessage.warning(`尚有 ${pendingIns} 人居保未匹配或未通过,请先完成居保匹配/人工确认后再提交领导`)
+    return
+  }
   const pending = await fetchPendingSpecialCount(row.town).catch(() => pendingSpecialCount.value)
   if (pending > 0) {
     ElMessage.warning(`本镇尚有 ${pending} 笔特殊业务待分管领导审批,全部通过后才能提交月度清单`)
@@ -751,6 +788,15 @@ async function loadData() {
   await loadList()
   const town = userStore.roleInfo?.allData ? undefined : userStore.roleInfo?.town
   pendingSpecialCount.value = await fetchPendingSpecialCount(town).catch(() => 0)
+  const nextIns: Record<string, number> = { ...unresolvedInsuranceMap.value }
+  await Promise.all(
+    list.value
+      .filter((row) => row.status === '待提交' && isCurrentBatch(row))
+      .map(async (row) => {
+        nextIns[row.id] = await fetchUnresolvedInsuranceCount(row.id).catch(() => nextIns[row.id] || 0)
+      }),
+  )
+  unresolvedInsuranceMap.value = nextIns
   for (const row of list.value) {
     if (['待提交', '待审核', '已审核'].includes(row.status)) {
       void refreshCalcLocked(row.id)

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="reports-view page-stack">
+  <div class="reports-view page-stack" v-loading="pageLoading">
     <div class="reports-view__toolbar">
       <p class="page-tip">{{ scopeTip }}</p>
       <div class="reports-view__actions">
@@ -422,7 +422,14 @@
         </el-form>
       </div>
 
-      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small" style="width: 100%">
+      <el-table
+        v-loading="peopleLoading || peopleTableLoading"
+        :data="pagedPeople"
+        border
+        stripe
+        size="small"
+        style="width: 100%"
+      >
         <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
         <el-table-column prop="name" label="姓名" min-width="90" />
         <el-table-column label="身份证" min-width="160">
@@ -593,7 +600,14 @@
         </el-form>
       </div>
 
-      <el-table v-loading="peopleLoading" :data="pagedPeople" border stripe size="small" style="width: 100%">
+      <el-table
+        v-loading="peopleLoading || peopleTableLoading"
+        :data="pagedPeople"
+        border
+        stripe
+        size="small"
+        style="width: 100%"
+      >
         <el-table-column prop="batchNo" label="批次号" min-width="100" align="center" />
         <el-table-column prop="name" label="姓名" min-width="90" />
         <el-table-column label="身份证" min-width="160">
@@ -745,6 +759,8 @@ const villageChartRows = ref<Array<{ villageId?: string; peopleCount?: number; a
   [],
 )
 const peopleLoading = ref(false)
+/** 首次进入 / 切角色:整页 loading(顶部汇总此前未挂 loading) */
+const pageLoading = ref(false)
 const exporting = ref(false)
 const topicLoading = ref(false)
 const yoyLoading = ref(false)
@@ -1185,6 +1201,7 @@ const {
   pageSize: peoplePageSize,
   total: peopleTotal,
   list: pagedPeople,
+  loading: peopleTableLoading,
   resetAndLoad: resetPeoplePage,
 } = useServerPagination<Person>(
   async (pageNum, pageSize) => {
@@ -1477,7 +1494,9 @@ function onPrint() {
 }
 
 async function loadAll() {
+  pageLoading.value = true
   peopleLoading.value = true
+  topicLoading.value = true
   try {
     const role = userStore.roleInfo
     const payMonth = (userStore.currentMonth || '').replace('-', '')
@@ -1509,6 +1528,7 @@ async function loadAll() {
     await Promise.all([loadTopics(), loadYoy(), resetPeoplePage()])
   } finally {
     peopleLoading.value = false
+    pageLoading.value = false
   }
 }
 
@@ -1527,6 +1547,8 @@ watch(
 
 <style scoped lang="less">
 .reports-view {
+  min-height: 320px;
+
   :deep(.el-table) {
     width: 100%;
   }

+ 256 - 148
src/views/special/SpecialBusinessView.vue

@@ -39,6 +39,26 @@
             @tab-change="onVillageTabChange"
           >
             <el-tab-pane v-for="v in villageTabs" :key="v" :label="v" :name="v">
+              <div v-if="activeVillage === v" class="special-business-view__status-search">
+                <el-input
+                  v-model="statusSearchName"
+                  placeholder="姓名"
+                  clearable
+                  size="small"
+                  style="width: 120px"
+                  @keyup.enter="onStatusSearch"
+                />
+                <el-input
+                  v-model="statusSearchIdCard"
+                  placeholder="身份证(可后几位)"
+                  clearable
+                  size="small"
+                  style="width: 160px"
+                  @keyup.enter="onStatusSearch"
+                />
+                <el-button size="small" type="primary" @click="onStatusSearch">查询</el-button>
+                <el-button size="small" @click="resetStatusSearch">重置</el-button>
+              </div>
               <div v-if="activeVillage === v" class="page-fill__table">
               <el-table
                 v-loading="peopleLoading"
@@ -57,9 +77,18 @@
                 <el-table-column label="标准(元/月)" width="100" align="right">
                   <template #default="{ row }">{{ formatAmount(row.standard) }}</template>
                 </el-table-column>
-                <el-table-column label="状态" width="110" align="center">
+                <el-table-column label="状态" width="140" align="center">
                   <template #default="{ row }">
                     <StatusTag :status="row.status" />
+                    <el-tag
+                      v-if="pendingSpecialLabel(row.id)"
+                      size="small"
+                      type="warning"
+                      effect="plain"
+                      style="margin-left: 4px"
+                    >
+                      {{ pendingSpecialLabel(row.id) }}
+                    </el-tag>
                   </template>
                 </el-table-column>
                 <el-table-column prop="startMonth" label="起始月份" width="100" align="center" />
@@ -141,6 +170,7 @@
           </div>
           <p class="page-tip">
             从本镇在册人员中选择并上传死亡证明;待分管领导通过后改为死亡丧葬。
+            下方列表为已提交的死亡登记记录(含待审/已通过/已驳回);选人弹窗仅展示在册且未死亡/未停止人员,已有待审登记的人员不可重复选择。
           </p>
           <div class="page-fill__table">
           <el-table v-loading="deathLoading" :data="pagedDeaths" border stripe size="small" height="100%">
@@ -365,22 +395,17 @@
     >
       <el-form :model="deathForm" label-width="120px" size="small">
         <el-form-item label="选择人员" required>
-          <el-select
-            v-model="deathForm.personId"
-            class="is-editable"
-            filterable
-            clearable
-            placeholder="请选择本镇在册人员"
-            style="width: 100%"
-            @change="onDeathPersonPick"
-          >
-            <el-option
-              v-for="p in deathPersonOptions"
-              :key="String(p.id)"
-              :label="`${p.name}(${maskIdCard(p.idCard)} · ${p.village})`"
-              :value="String(p.id)"
+          <div class="person-pick-field">
+            <el-input
+              :model-value="deathPersonLabel"
+              readonly
+              placeholder="请点击右侧按钮选择本镇在册人员"
             />
-          </el-select>
+            <el-button type="primary" @click="openDeathPersonPicker">选择</el-button>
+            <el-button v-if="deathForm.personId" link type="danger" @click="clearDeathPerson">
+              清除
+            </el-button>
+          </div>
         </el-form-item>
         <el-form-item label="姓名" required>
           <el-input v-model="deathForm.personName" disabled />
@@ -645,65 +670,33 @@
           </p>
         </el-form-item>
         <el-form-item v-if="isFuneralForm" label="选择人员" required>
-          <el-select
-            v-model="specialForm.personId"
-            class="is-editable"
-            filterable
-            clearable
-            placeholder="请选择本镇人员(优先已死亡登记)"
-            style="width: 100%"
-            @change="onSpecialPersonPick"
-          >
-            <el-option
-              v-for="p in funeralPersonOptions"
-              :key="String(p.id)"
-              :label="`${p.name}(${maskIdCard(p.idCard)} · ${p.village} · ${p.status})`"
-              :value="String(p.id)"
+          <div class="person-pick-field">
+            <el-input
+              :model-value="specialPersonLabel"
+              readonly
+              placeholder="请点击选择本镇人员(优先已死亡登记)"
             />
-          </el-select>
+            <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 v-else-if="isRefundForm">
-          <el-form-item label="筛选村居">
-            <el-select
-              v-model="refundPersonFilter.village"
-              class="is-editable"
-              clearable
-              filterable
-              placeholder="全部村居"
-              style="width: 100%"
-            >
-              <el-option v-for="v in regionVillages" :key="v" :label="v" :value="v" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="姓名/证件">
-            <el-input
-              v-model="refundPersonFilter.keyword"
-              class="is-editable"
-              clearable
-              placeholder="输入姓名或身份证号筛选"
-            />
-          </el-form-item>
           <el-form-item label="选择人员" required>
-            <el-select
-              v-model="specialForm.personId"
-              class="is-editable"
-              filterable
-              clearable
-              placeholder="请从列表选择本镇人员"
-              style="width: 100%"
-              @change="onSpecialPersonPick"
-            >
-              <el-option
-                v-for="p in refundPersonOptions"
-                :key="String(p.id)"
-                :label="`${p.name}(${maskIdCard(p.idCard)} · ${p.village} · ${p.status})`"
-                :value="String(p.id)"
+            <div class="person-pick-field">
+              <el-input
+                :model-value="specialPersonLabel"
+                readonly
+                placeholder="请点击选择本镇人员"
               />
-            </el-select>
-            <p class="special-business-view__field-tip">
-              可选 {{ refundPersonOptions.length }} 人;选定后街镇/村居自动带入。
-            </p>
+              <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>
@@ -852,6 +845,16 @@
         <el-button type="primary" :loading="saving" @click="confirmRefundProgress">确定</el-button>
       </template>
     </el-dialog>
+    <!-- 选人弹窗:死亡 / 丧葬 / 多发退款共用 -->
+    <PersonPickerDialog
+      v-model:visible="personPickerVisible"
+      :title="personPickerTitle"
+      :tip="personPickerTip"
+      :town="personPickerTown"
+      :exclude-statuses="personPickerExcludeStatuses"
+      :exclude-ids="personPickerExcludeIds"
+      @select="onPersonPicked"
+    />
   </div>
 </template>
 
@@ -862,6 +865,7 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 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 type { UploadFile, UploadUserFile } from 'element-plus'
 import {
@@ -907,6 +911,8 @@ const adjustTriggering = ref(false)
 const saving = ref(false)
 const deathDialogVisible = ref(false)
 const deathDetailVisible = ref(false)
+const personPickerVisible = ref(false)
+const personPickerMode = ref<'death' | 'funeral' | 'refund'>('death')
 const deathDetail = ref<DeathRecord | null>(null)
 const deathFlowLogs = ref<ProcessLogItem[]>([])
 const deathFlowLoading = ref(false)
@@ -978,6 +984,9 @@ const canOperate = computed(() =>
 const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
 /** 系统管理员当前操作的街镇 */
 const adminTown = ref('')
+const statusSearchName = ref('')
+const statusSearchIdCard = ref('')
+const pendingSpecialByPerson = ref<Map<string, string>>(new Map())
 
 /** 当前生效街镇:管理员用所选街镇,其余用角色绑定街镇 */
 const effectiveTown = computed(() =>
@@ -1041,9 +1050,12 @@ const adjustVillageTabs = computed(() => {
 function onAdminTownChange() {
   activeVillage.value = ''
   adjustActiveVillage.value = ''
+  statusSearchName.value = ''
+  statusSearchIdCard.value = ''
   resetAdjustRosterPage()
   void loadPeople()
   void loadDeaths()
+  void loadPendingSpecialMap()
   loadAdjustRoster()
 }
 
@@ -1107,6 +1119,8 @@ const {
   return fetchPeoplePage({
     town: town || undefined,
     village: activeVillage.value || undefined,
+    name: statusSearchName.value.trim() || undefined,
+    idCard: statusSearchIdCard.value.trim() || undefined,
     pageNum,
     pageSize,
   })
@@ -1171,28 +1185,6 @@ const deathForm = reactive({
   heirContact: '',
 })
 
-/** 已有在途死亡登记的人员 ID(待分管领导审批) */
-const pendingDeathPersonIds = computed(() => {
-  const set = new Set<string>()
-  for (const d of deaths.value) {
-    if (d.status === '待分管领导审批' && d.personnelId) set.add(String(d.personnelId))
-  }
-  return set
-})
-
-/** 死亡录入:本镇可选在册人员(排除已死亡/停止/在途登记) */
-const deathPersonOptions = computed(() => {
-  const town = effectiveTown.value
-  if (!town) return [] as Person[]
-  return people.value.filter(
-    (p) =>
-      p.town === town &&
-      !p.status.includes('死亡丧葬') &&
-      !['停止', '已停止发放', '已归档'].includes(p.status) &&
-      !pendingDeathPersonIds.value.has(String(p.id)),
-  )
-})
-
 const specialForm = reactive({
   type: '暂停发放',
   personId: '' as string,
@@ -1206,31 +1198,73 @@ const specialForm = reactive({
   refundMethod: 'DEDUCT_NEXT' as 'DEDUCT_NEXT' | 'RETURN_FISCAL',
 })
 
-const isFuneralForm = computed(() => specialForm.type === '丧葬补贴')
-const isRefundForm = computed(() => specialForm.type === '多发退款')
+/** 已有在途死亡登记的人员 ID(待分管领导审批) */
+const pendingDeathPersonIds = computed(() => {
+  const set = new Set<string>()
+  for (const d of deaths.value) {
+    if (d.status === '待分管领导审批' && d.personnelId) set.add(String(d.personnelId))
+  }
+  return set
+})
 
-const refundPersonFilter = reactive({
-  village: '',
-  keyword: '',
+const deathPersonLabel = computed(() => {
+  if (!deathForm.personId) return ''
+  const parts = [deathForm.personName]
+  const detail = [deathForm.idCard ? maskIdCard(deathForm.idCard) : '', deathForm.village]
+    .filter(Boolean)
+    .join(' · ')
+  if (detail) parts.push(`(${detail})`)
+  return parts.join('')
 })
 
-/** 多发退款:本镇在册人员(支持村居/姓名/证件筛选) */
-const refundPersonOptions = computed(() => {
-  const town = specialForm.town || effectiveTown.value
-  if (!town) return [] as Person[]
-  const kw = refundPersonFilter.keyword.trim().toLowerCase()
-  const village = refundPersonFilter.village.trim()
-  return people.value.filter((p) => {
-    if (p.town !== town) return false
-    if (['已归档', '无资格'].includes(p.status)) return false
-    if (village && p.village !== village) return false
-    if (!kw) return true
-    const name = String(p.name || '').toLowerCase()
-    const id = String(p.idCard || '').toLowerCase()
-    return name.includes(kw) || id.includes(kw)
-  })
+const specialPersonLabel = computed(() => {
+  if (!specialForm.personId) return ''
+  return specialForm.village
+    ? `${specialForm.personName}(${specialForm.village})`
+    : specialForm.personName
+})
+
+const personPickerTown = computed(
+  () =>
+    (personPickerMode.value === 'death' ? deathForm.town : specialForm.town) ||
+    effectiveTown.value ||
+    '',
+)
+
+const personPickerTitle = computed(() => {
+  if (personPickerMode.value === 'death') return '选择死亡登记人员'
+  if (personPickerMode.value === 'funeral') return '选择丧葬补贴人员'
+  return '选择多发退款人员'
+})
+
+const personPickerTip = computed(() => {
+  if (personPickerMode.value === 'death') {
+    return '名单来源:本镇在册人员库(不含已死亡丧葬/已停止/已归档);已有待审死亡登记的人员不可选。支持村居、姓名、身份证筛选。'
+  }
+  if (personPickerMode.value === 'funeral') {
+    return '建议优先选择已完成死亡登记的人员;双击行也可选定。'
+  }
+  return '选定后自动带入街镇/村居;支持村居、姓名、身份证筛选。'
+})
+
+const personPickerExcludeStatuses = computed(() => {
+  if (personPickerMode.value === 'death') {
+    return ['死亡丧葬', '停止', '已停止发放', '已归档']
+  }
+  if (personPickerMode.value === 'funeral') {
+    return ['停止', '已停止发放', '已归档', '无资格']
+  }
+  return ['已归档', '无资格']
+})
+
+const personPickerExcludeIds = computed(() => {
+  if (personPickerMode.value === 'death') return [...pendingDeathPersonIds.value]
+  return [] as string[]
 })
 
+const isFuneralForm = computed(() => specialForm.type === '丧葬补贴')
+const isRefundForm = computed(() => specialForm.type === '多发退款')
+
 const specialDialogTitle = computed(() => {
   if (isFuneralForm.value) return '丧葬补贴申请'
   if (isRefundForm.value) return '多发退款申请'
@@ -1238,17 +1272,6 @@ const specialDialogTitle = computed(() => {
   return '特殊业务申请'
 })
 
-/** 丧葬补贴:本镇可选人员(含已死亡登记,排除停止/归档) */
-const funeralPersonOptions = computed(() => {
-  const town = effectiveTown.value
-  if (!town) return [] as Person[]
-  return people.value.filter(
-    (p) =>
-      p.town === town &&
-      !['停止', '已停止发放', '已归档', '无资格'].includes(p.status),
-  )
-})
-
 const specialFileList = ref<UploadUserFile[]>([])
 const specialRawFiles = ref<File[]>([])
 const funeralDetailVisible = ref(false)
@@ -1410,14 +1433,7 @@ function onDeathDetailClosed() {
   deathFlowLogs.value = []
 }
 
-function onDeathPersonPick(id: string) {
-  const p = deathPersonOptions.value.find((x) => String(x.id) === String(id))
-  if (!p) {
-    deathForm.personName = ''
-    deathForm.idCard = ''
-    deathForm.village = ''
-    return
-  }
+function applyDeathPerson(p: Person) {
   deathForm.personId = String(p.id)
   deathForm.personName = p.name
   deathForm.idCard = p.idCard
@@ -1425,12 +1441,29 @@ function onDeathPersonPick(id: string) {
   deathForm.village = p.village
 }
 
+function clearDeathPerson() {
+  deathForm.personId = ''
+  deathForm.personName = ''
+  deathForm.idCard = ''
+  deathForm.village = ''
+}
+
+function openDeathPersonPicker() {
+  const town = deathForm.town || effectiveTown.value
+  if (!town) {
+    ElMessage.warning('请先选择街镇')
+    return
+  }
+  deathForm.town = town
+  personPickerMode.value = 'death'
+  personPickerVisible.value = true
+}
+
 async function openDeathDialog() {
   if (isAdmin.value && !adminTown.value) {
     ElMessage.warning('请先选择街镇')
     return
   }
-  if (!people.value.length) await loadPeople()
   await resetDeathForm()
   deathDialogVisible.value = true
 }
@@ -1468,6 +1501,49 @@ watch(
 )
 
 function onVillageTabChange() {
+  peoplePage.value = 1
+  void loadPeople()
+}
+
+async function loadPendingSpecialMap() {
+  const town = effectiveTown.value
+  if (isAdmin.value && !town) {
+    pendingSpecialByPerson.value = new Map()
+    return
+  }
+  try {
+    const res = await fetchSpecialsPage({
+      town: town || undefined,
+      approveStatus: '待分管领导审批',
+      pageNum: 1,
+      pageSize: 500,
+    })
+    const map = new Map<string, string>()
+    for (const s of res.list) {
+      if (s.status !== '待分管领导审批' || !s.personnelId) continue
+      const label = `${s.type}待审`
+      map.set(String(s.personnelId), label)
+    }
+    pendingSpecialByPerson.value = map
+  } catch {
+    pendingSpecialByPerson.value = new Map()
+  }
+}
+
+function pendingSpecialLabel(personId: number | string | undefined) {
+  if (personId == null || personId === '') return ''
+  return pendingSpecialByPerson.value.get(String(personId)) || ''
+}
+
+function onStatusSearch() {
+  peoplePage.value = 1
+  void loadPeople()
+}
+
+function resetStatusSearch() {
+  statusSearchName.value = ''
+  statusSearchIdCard.value = ''
+  peoplePage.value = 1
   void loadPeople()
 }
 
@@ -1521,7 +1597,6 @@ async function openSpecialDialog(type: string, person?: Person) {
     ElMessage.warning('请先选择街镇')
     return
   }
-  if ((type === '丧葬补贴' || type === '多发退款') && !people.value.length) await loadPeople()
   specialForm.type = type
   specialForm.personId = person ? String(person.id) : ''
   specialForm.personName = person?.name || ''
@@ -1544,8 +1619,6 @@ async function openSpecialDialog(type: string, person?: Person) {
   }
   specialForm.heirName = ''
   specialForm.refundMethod = 'DEDUCT_NEXT'
-  refundPersonFilter.village = ''
-  refundPersonFilter.keyword = ''
   specialFileList.value = []
   specialRawFiles.value = []
   specialDialogVisible.value = true
@@ -1559,8 +1632,6 @@ function onSpecialDialogClosed() {
   specialForm.personName = ''
   specialForm.village = ''
   specialForm.refundMethod = 'DEDUCT_NEXT'
-  refundPersonFilter.village = ''
-  refundPersonFilter.keyword = ''
   funeralAmountTip.value = ''
   funeralAmountLoading.value = false
 }
@@ -1578,18 +1649,7 @@ function onSpecialFileRemove(_file: UploadFile, fileList: UploadFile[]) {
   syncSpecialRawFiles(fileList)
 }
 
-async function onSpecialPersonPick(id: string) {
-  const p =
-    funeralPersonOptions.value.find((x) => String(x.id) === String(id)) ||
-    refundPersonOptions.value.find((x) => String(x.id) === String(id)) ||
-    deathPersonOptions.value.find((x) => String(x.id) === String(id)) ||
-    people.value.find((x) => String(x.id) === String(id))
-  if (!p) {
-    specialForm.personId = ''
-    specialForm.personName = ''
-    specialForm.village = ''
-    return
-  }
+async function applySpecialPerson(p: Person) {
   specialForm.personId = String(p.id)
   specialForm.personName = p.name
   const prevTown = specialForm.town
@@ -1600,6 +1660,31 @@ async function onSpecialPersonPick(id: string) {
   }
 }
 
+function clearSpecialPerson() {
+  specialForm.personId = ''
+  specialForm.personName = ''
+  specialForm.village = ''
+}
+
+function openSpecialPersonPicker() {
+  const town = specialForm.town || effectiveTown.value
+  if (!town) {
+    ElMessage.warning('请先选择街镇')
+    return
+  }
+  specialForm.town = town
+  personPickerMode.value = isFuneralForm.value ? 'funeral' : 'refund'
+  personPickerVisible.value = true
+}
+
+async function onPersonPicked(p: Person) {
+  if (personPickerMode.value === 'death') {
+    applyDeathPerson(p)
+    return
+  }
+  await applySpecialPerson(p)
+}
+
 function openFuneralDetail(row: SpecialBiz) {
   funeralDetail.value = row
   funeralDetailVisible.value = true
@@ -1776,6 +1861,7 @@ async function onPersonAction(row: Person, action: string) {
   ElMessage.success(`已提交「${row.name}」${action}申请,待分管领导审批`)
   await loadPeople()
   await loadSpecials()
+  await loadPendingSpecialMap()
 }
 
 async function confirmStop() {
@@ -1803,6 +1889,7 @@ async function confirmStop() {
     stopDialogVisible.value = false
     await loadPeople()
     await loadSpecials()
+    await loadPendingSpecialMap()
   } finally {
     saving.value = false
   }
@@ -1993,6 +2080,7 @@ onMounted(async () => {
     loadSpecials(),
     loadDeaths(),
     loadPeople(),
+    loadPendingSpecialMap(),
     loadAdjustRoster(),
     loadRefundStats(),
   ])
@@ -2066,6 +2154,15 @@ watch(
     flex-shrink: 0;
   }
 
+  &__status-search {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 8px;
+    flex-shrink: 0;
+  }
+
   &__village-tabs {
     flex: 1;
     min-height: 0;
@@ -2178,4 +2275,15 @@ watch(
     font-size: 12px;
   }
 }
+
+.person-pick-field {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+
+  .el-input {
+    flex: 1;
+  }
+}
 </style>