Selaa lähdekoodia

业务流程误解调整。

DESKTOP-6LTVLN7\Liumouren 1 kuukausi sitten
vanhempi
sitoutus
453f878cf4
35 muutettua tiedostoa jossa 2860 lisäystä ja 1393 poistoa
  1. 72 11
      src/api/biz.ts
  2. 30 0
      src/api/mock/data.ts
  3. 37 12
      src/api/sjnmtybt/mappers.ts
  4. 518 270
      src/api/sjnmtybt/realBiz.ts
  5. 5 0
      src/components/biz/BatchDetailDrawer.vue
  6. 12 28
      src/components/biz/PersonStatusTimeline.vue
  7. 102 23
      src/components/biz/PersonnelDetailDescriptions.vue
  8. 22 2
      src/components/biz/PersonnelFormFields.vue
  9. 63 0
      src/composables/useSubmitLock.ts
  10. 1 1
      src/constants/personnelFields.ts
  11. 3 0
      src/constants/submitLock.ts
  12. 7 0
      src/layouts/MainLayout.vue
  13. 130 3
      src/styles/index.less
  14. 8 0
      src/types/biz.ts
  15. 28 14
      src/utils/batchAdjust.ts
  16. 109 56
      src/utils/buildDashboard.ts
  17. 41 0
      src/utils/personnelStatusLog.ts
  18. 134 3
      src/utils/request.ts
  19. 15 2
      src/utils/statusLabels.ts
  20. 4 1
      src/utils/statusTag.ts
  21. 52 4
      src/utils/validators.ts
  22. 66 36
      src/views/admin/AdminBatchesView.vue
  23. 252 22
      src/views/admin/PersonnelAdminView.vue
  24. 17 15
      src/views/admin/RegionsAdminView.vue
  25. 21 16
      src/views/alerts/AlertsView.vue
  26. 369 141
      src/views/approvals/ApprovalsView.vue
  27. 273 546
      src/views/base/BaseInfoView.vue
  28. 120 41
      src/views/dashboard/DashboardView.vue
  29. 11 12
      src/views/disk/DiskReturnView.vue
  30. 135 36
      src/views/insurance/InsuranceMatchView.vue
  31. 56 22
      src/views/monthly/MonthlyPaymentView.vue
  32. 35 25
      src/views/reports/ReportsView.vue
  33. 67 19
      src/views/special/SpecialBusinessView.vue
  34. 18 16
      src/views/status/StatusMaintenanceView.vue
  35. 27 16
      src/views/system/SystemAdminView.vue

+ 72 - 11
src/api/biz.ts

@@ -65,6 +65,28 @@ export function fetchVillageBatches() {
   return real.fetchVillageBatches()
 }
 
+export function submitRetireQualify(
+  personnelId: string,
+  payload?: { opinion?: string; fileUrls?: string },
+) {
+  if (useMock()) return delay({ ok: true })
+  return real.submitRetireQualify(personnelId, payload)
+}
+
+export function generateDistrictMonthBatch(
+  payMonth?: string,
+  matchInsurance = true,
+  simulateFirstDay = false,
+) {
+  if (useMock()) return delay({ ok: true })
+  return real.generateDistrictMonthBatch(payMonth, matchInsurance, simulateFirstDay)
+}
+
+export function fetchPendingSpecialCount(town?: string) {
+  if (useMock()) return delay(0)
+  return real.fetchPendingSpecialCount(town)
+}
+
 export function fetchPeople(params?: {
   batchNo?: string
   town?: string
@@ -212,7 +234,6 @@ export function savePerson(payload: Partial<Person>) {
           standard: payload.standard ?? hit.standard,
           startMonth: payload.startMonth ?? hit.startMonth,
           remark: payload.remark ?? hit.remark,
-          retireQualifyType: payload.retireQualifyType ?? hit.retireQualifyType,
           source: payload.source ?? hit.source,
         })
         return delay({ ok: true })
@@ -235,7 +256,6 @@ export function savePerson(payload: Partial<Person>) {
       source: '村合作社录入',
       insuranceStatus: '未匹配',
       remark: payload.remark,
-      retireQualifyType: payload.retireQualifyType,
     })
     return delay({ ok: true })
   }
@@ -277,7 +297,13 @@ export function fetchBatchMemberIds(batchId: string) {
 /** 本批次人员明细(按发放明细,非全村人员库) */
 export function fetchBatchMemberPeople(
   batchId: string,
-  opts?: { batchNo?: string; town?: string; village?: string; pruneDefaults?: boolean },
+  opts?: {
+    batchNo?: string
+    town?: string
+    village?: string
+    pruneDefaults?: boolean
+    parentBatchId?: string
+  },
 ) {
   if (useMock()) {
     const batch = mockVillageBatches.find((b) => b.id === batchId)
@@ -506,15 +532,20 @@ export function addBatchProcessNote(payload: {
 }
 
 /* ========== 居保匹配 ========== */
-export function fetchInsuranceBatches() {
-  if (useMock()) {
-    return delay(
-      mockVillageBatches.filter(
-        (b) => b.stage === '居保状态匹配' || b.stage === '比对结果已生成',
-      ),
-    )
+export function fetchInsuranceBatches(params?: { tab?: 'todo' | 'history' }) {
+  if (useMock()) {
+    const todo = new Set(['居保状态匹配', '比对结果已生成'])
+    const history = new Set([
+      '分管领导审批',
+      '待线下发放',
+      '回盘信息确认',
+      '已回盘确认',
+      '已归档',
+    ])
+    const stages = params?.tab === 'history' ? history : todo
+    return delay(mockVillageBatches.filter((b) => stages.has(b.stage)))
   }
-  return real.fetchInsuranceBatches()
+  return real.fetchInsuranceBatches(params)
 }
 
 /** 查询居保匹配结果;可按批次号 / 街镇 / 村居过滤 */
@@ -661,6 +692,9 @@ export function fetchApprovals(params?: {
   if (useMock()) {
     let list = [...mockApprovals]
     if (params?.tab === 'pending') list = list.filter((a) => a.status === '待审批')
+    if (params?.tab === 'history') {
+      list = list.filter((a) => a.status === '已通过' || a.status === '已驳回')
+    }
     if (params?.node) list = list.filter((a) => a.node === params.node)
     if (params?.town) list = list.filter((a) => a.town === params.town)
     if (params?.batchNo) list = list.filter((a) => a.batchNo === params.batchNo)
@@ -702,6 +736,7 @@ export function decideApproval(payload: {
   pass: boolean
   opinion?: string
   fileUrls?: string
+  retireQualifyType?: '有土地退养' | '无土地有资格' | '无资格'
 }) {
   if (useMock()) {
     const appr = mockApprovals.find((a) => a.id === payload.id)
@@ -1829,6 +1864,11 @@ export function deletePersonnelAdmin(personnelId: string) {
   return real.deletePersonnelAdmin(personnelId)
 }
 
+export function deletePersonnelBatchAdmin(ids: string[]) {
+  if (useMock()) return delay({ ok: true as const, successCount: ids.length, failCount: 0, message: `已删除 ${ids.length} 人` })
+  return real.deletePersonnelBatchAdmin(ids)
+}
+
 export function updatePersonnelStatusAdmin(payload: {
   personnelId: string
   bizStatus: string
@@ -1839,6 +1879,27 @@ export function updatePersonnelStatusAdmin(payload: {
   return real.updatePersonnelStatusAdmin(payload)
 }
 
+export function initPersonnelStatusAdmin(payload: {
+  bizStatus: string
+  insuranceMatchResult: string
+  retireQualifyType?: string
+  payThisMonth?: boolean
+  townId?: string
+  villageId?: string
+}) {
+  if (useMock()) {
+    return delay({
+      ok: true as const,
+      updatedCount: 0,
+      skippedArchived: 0,
+      skippedSame: 0,
+      failCount: 0,
+      message: '已初始化(演示)',
+    })
+  }
+  return real.initPersonnelStatusAdmin(payload)
+}
+
 /* ========== 核算 / 退款 / 报表 / 运维扩展 ========== */
 export function snapshotCalcRound(batchId: string) {
   if (useMock()) return delay({ ok: true })

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

@@ -400,6 +400,36 @@ export const mockApprovals: Approval[] = [
     opinion: '同意',
     createdAt: '2026-07-30 16:40',
   },
+  {
+    id: 'AP202607101',
+    bizType: '月度批次人员申请',
+    batchNo: '202607',
+    town: '佘山镇',
+    village: '辰山村',
+    title: '辰山村2026年7月批次人员申请',
+    applicant: '辰山村合作社',
+    node: '镇经发中心初审',
+    status: '已通过',
+    opinion: '同意',
+    createdAt: '2026-07-16 09:40',
+    peopleCount: 11,
+    totalAmount: 14080,
+  },
+  {
+    id: 'AP202607102',
+    bizType: '月度批次人员申请',
+    batchNo: '202607',
+    town: '佘山镇',
+    village: '岳阳村',
+    title: '岳阳村2026年7月批次人员申请',
+    applicant: '岳阳村合作社',
+    node: '镇经发中心初审',
+    status: '已驳回',
+    opinion: '名单人员资格不符,退回村级修改',
+    createdAt: '2026-07-18 14:12',
+    peopleCount: 8,
+    totalAmount: 10240,
+  },
 ]
 
 /** 调标补发名单(触发申请后按镇生成,对接接口后由后端返回) */

+ 37 - 12
src/api/sjnmtybt/mappers.ts

@@ -41,11 +41,11 @@ const BATCH_STATUS_TO_MONTHLY: Record<string, string> = {
 }
 
 const PERSON_STATUS_LABEL: Record<string, string> = {
-  DRAFT: '草稿',
-  // 与批次「待镇经发初审」区分:人员侧表示随村材料待镇审
-  PENDING_TOWN_APPROVE: '待镇初审',
-  // 兼容旧文案/字典「待镇审批」
-  待镇审批: '待镇初审',
+  DRAFT: '待提交资格',
+  PENDING_TOWN_APPROVE: '待资格审批',
+  待镇审批: '待资格审批',
+  待镇初审: '待资格审批',
+  NEW: '新增',
   PENDING_INSURANCE_MATCH: '待居保匹配',
   NORMAL: '正常发放',
   EXITED: '已退出',
@@ -88,6 +88,7 @@ const ALERT_TYPE_LABEL: Record<string, string> = {
   LAND_MATCH_FAIL: '土地匹配不符合',
   APPROVAL_TIMEOUT: '审批超时',
   REFUND_FISCAL: '多发退款待退缴',
+  QUALIFY_REJECT: '退养资格驳回',
   // 兼容历史/演示文案
   民政死亡比对: '死亡停发预警',
   回盘发放失败: '回盘失败',
@@ -172,14 +173,16 @@ export function fmtTime(ts?: number | string | null): string {
 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 || '草稿'
-  // 居保失败待人工:页面用「待人工确认」
-  if (ins === 'FAIL' || status === '异常待处理') status = '待人工确认'
   // 退养资格:正式字段优先,备注兼容历史数据
   const remarks = String(raw.remarks || '')
   const qualifyCode = String(raw.retireQualifyType || '').toUpperCase()
   let retireQualifyType: Person['retireQualifyType']
-  if (qualifyCode === 'INELIGIBLE' || qualifyCode === '无资格') {
+  if (qualifyCode === 'UNSET' || qualifyCode === 'NONE' || qualifyCode === 'CLEARED') {
+    retireQualifyType = undefined
+  } else if (qualifyCode === 'INELIGIBLE' || qualifyCode === '无资格') {
     retireQualifyType = '无资格'
   } else if (qualifyCode === 'WITHOUT_LAND' || qualifyCode === '无土地有资格') {
     retireQualifyType = '无土地有资格'
@@ -192,6 +195,10 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
   } else if (remarks.includes('有土地') || remarks.includes('资格已核验')) {
     retireQualifyType = '有土地退养'
   }
+  // 待提交资格尚未生效,不展示上次通过留下的有地/无地
+  if (biz === 'DRAFT' && retireQualifyType !== '无资格') {
+    retireQualifyType = undefined
+  }
   // 人员状态一律跟 bizStatus,各页一致;退养资格用 retireQualifyType 单独展示
   // 仅「无资格」在业务上等同异常终态,统一展示为「无资格」
   if (retireQualifyType === '无资格') {
@@ -305,9 +312,11 @@ export function mapVillageBatch(raw: Record<string, unknown>): VillageBatch {
     batchNo,
     month: payMonthToMonth(payMonth),
     town: regionName(String(raw.townId || '')),
-    village: regionName(String(raw.villageId || '')),
+    village: villageLabelOfBatch(raw),
     peopleCount: Number(raw.peopleCount || 0),
     totalAmount: Number(raw.totalAmount || 0),
+    parentBatchId: raw.parentBatchId ? String(raw.parentBatchId) : undefined,
+    batchLevel: raw.batchLevel ? String(raw.batchLevel) : undefined,
     stage,
     submitter: String(raw.submitter || ''),
     submittedAt: resolveSubmittedAt(raw),
@@ -318,12 +327,21 @@ export function mapVillageBatch(raw: Record<string, unknown>): VillageBatch {
     exportFileName: raw.exportFileName ? String(raw.exportFileName) : undefined,
     townOpinion: raw.townOpinion ? String(raw.townOpinion) : undefined,
     remarks: raw.remarks ? String(raw.remarks) : undefined,
+    yearlyAdjust: raw.yearlyAdjust === true || raw.yearlyAdjust === 'true',
+    calcLocked: raw.calcLocked === true || raw.calcLocked === 'true',
     normalAmount: Number(raw.normalAmount || 0) || undefined,
     supplementAmount: Number(raw.supplementAmount || 0) || undefined,
     deductAmount: Number(raw.deductAmount || 0) || undefined,
   }
 }
 
+function villageLabelOfBatch(raw: Record<string, unknown>): string {
+  const level = String(raw.batchLevel || '')
+  if (level === 'DISTRICT') return '全区'
+  if (level === 'TOWN') return '本镇'
+  return regionName(String(raw.villageId || ''))
+}
+
 
 function stripProcessFileUrls(raw?: string): string | undefined {
   if (!raw) return undefined
@@ -404,6 +422,8 @@ export function mapVillageMonthly(raw: Record<string, unknown>): VillageMonthlyB
     stage: b.stage,
     submitter: b.submitter,
     submittedAt: b.submittedAt,
+    parentBatchId: b.parentBatchId,
+    batchLevel: b.batchLevel,
   }
 }
 
@@ -588,11 +608,16 @@ export function approvalIdBatch(batchId: string) {
 export function approvalIdSpecial(specialId: string) {
   return `S:${specialId}`
 }
-export function parseApprovalId(id: string): { kind: 'batch' | 'special'; bizId: string } | null {
+export function approvalIdQualify(personnelId: string) {
+  return `Q:${personnelId}`
+}
+export function parseApprovalId(id: string): { kind: 'batch' | 'special' | 'qualify'; bizId: string } | null {
   if (id.startsWith('B:')) return { kind: 'batch', bizId: id.slice(2) }
   if (id.startsWith('S:')) return { kind: 'special', bizId: id.slice(2) }
-  // 兼容直接传批次/特殊单 ID
-  if (id.startsWith('VB') || id.startsWith('MB')) return { kind: 'batch', bizId: id }
+  if (id.startsWith('Q:')) return { kind: 'qualify', bizId: id.slice(2) }
+  if (id.startsWith('VB') || id.startsWith('MB') || id.startsWith('DB') || id.startsWith('TB')) {
+    return { kind: 'batch', bizId: id }
+  }
   return { kind: 'special', bizId: id }
 }
 

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 518 - 270
src/api/sjnmtybt/realBiz.ts


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

@@ -338,6 +338,7 @@ import {
 import { actionLabel } from '@/constants/batchActions'
 import { resultLabelOf } from '@/utils/statusLabels'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy } from '@/composables/useSubmitLock'
 import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
@@ -417,6 +418,8 @@ const isAdjustBatch = computed(() =>
     month: batch.value?.month,
     id: batch.value?.id,
     remarks: batch.value?.remarks,
+    batchLevel: (batch.value as { batchLevel?: string } | undefined)?.batchLevel,
+    village: batch.value?.village,
   }),
 )
 
@@ -504,6 +507,7 @@ async function reloadFlow(force = false) {
 
 async function onExportFlow() {
   if (!props.batchId) return
+  if (guardBusy(exportingFlow)) return
   exportingFlow.value = true
   try {
     await exportProcessTimeline({ batchId: props.batchId })
@@ -551,6 +555,7 @@ async function saveNote() {
     ElMessage.warning('请填写备注或上传附件')
     return
   }
+  if (guardBusy(noteSaving)) return
   noteSaving.value = true
   try {
     const uploaded: string[] = []

+ 12 - 28
src/components/biz/PersonStatusTimeline.vue

@@ -4,6 +4,7 @@
       <el-radio-group v-model="filter" size="small">
         <el-radio-button label="all">全部</el-radio-button>
         <el-radio-button label="biz">业务状态</el-radio-button>
+        <el-radio-button label="qualify">资格</el-radio-button>
         <el-radio-button label="insurance">居保匹配</el-radio-button>
       </el-radio-group>
     </div>
@@ -61,6 +62,7 @@ import {
   roleLabelOf,
   tryLabelOf,
 } from '@/utils/statusLabels'
+import { isPersonnelBizStatusLog, isPersonnelInsuranceLog, isPersonnelQualifyLog } from '@/utils/personnelStatusLog'
 import type { ProcessLogItem } from '@/types/biz'
 
 const props = withDefaults(
@@ -69,7 +71,7 @@ const props = withDefaults(
     loading?: boolean
     showFilter?: boolean
     /** 默认筛选项 */
-    initialFilter?: 'all' | 'biz' | 'insurance'
+    initialFilter?: 'all' | 'biz' | 'insurance' | 'qualify'
   }>(),
   {
     logs: () => [],
@@ -79,7 +81,7 @@ const props = withDefaults(
   },
 )
 
-type FilterKey = 'all' | 'biz' | 'insurance'
+type FilterKey = 'all' | 'biz' | 'insurance' | 'qualify'
 const filter = ref<FilterKey>(props.initialFilter || 'all')
 
 watch(
@@ -92,40 +94,18 @@ watch(
 const emptyText = computed(() => {
   if (filter.value === 'biz') return '暂无业务状态变更记录'
   if (filter.value === 'insurance') return '暂无居保匹配变更记录'
+  if (filter.value === 'qualify') return '暂无退养资格审批记录'
   return '暂无状态变更日志'
 })
 
 const filtered = computed(() => {
   const list = props.logs || []
   if (filter.value === 'all') return list
-  if (filter.value === 'insurance') return list.filter(isInsuranceLog)
-  return list.filter(isBizStatusLog)
+  if (filter.value === 'insurance') return list.filter(isPersonnelInsuranceLog)
+  if (filter.value === 'qualify') return list.filter(isPersonnelQualifyLog)
+  return list.filter(isPersonnelBizStatusLog)
 })
 
-function textOf(log: ProcessLogItem) {
-  return [
-    log.action,
-    log.actionLabel,
-    log.bizType,
-    log.status,
-    log.opinion,
-    log.remarks,
-    log.result,
-  ]
-    .filter(Boolean)
-    .join('|')
-}
-
-function isInsuranceLog(log: ProcessLogItem) {
-  return /居保|INSURANCE|MATCH|匹配|比对|MANUAL_PASS|LOSE_ELIGIBILITY/i.test(textOf(log))
-}
-
-function isBizStatusLog(log: ProcessLogItem) {
-  return /暂停|停止|恢复|STATUS_|PAUSE|RESUME|STOPPED|NORMAL|死亡|丧葬|归档|DRAFT|业务状态|发放/i.test(
-    textOf(log),
-  )
-}
-
 function actionText(log: ProcessLogItem) {
   const raw = log.actionLabel || log.action || '办理'
   return processActionLabelOf(raw)
@@ -148,6 +128,10 @@ function bizTypeLabel(code?: string) {
 function statusDisplay(code?: string) {
   const s = String(code || '').trim()
   if (!s) return ''
+  if (s === 'WITH_LAND') return '有土地退养'
+  if (s === 'WITHOUT_LAND') return '无土地有资格'
+  if (s === 'INELIGIBLE') return '无资格'
+  if (s === 'UNSET' || s === 'NONE' || s === 'CLEARED') return '待认定'
   return (
     tryLabelOf('insuranceMatch', s) ||
     tryLabelOf('personnelStatus', s) ||

+ 102 - 23
src/components/biz/PersonnelDetailDescriptions.vue

@@ -24,11 +24,7 @@
         >
           <template v-if="isStatusField(f.key)">
             <span class="personnel-detail-descriptions__status-cell">
-              <StatusTag
-                v-if="f.key === 'status' || f.key === 'insuranceStatus'"
-                :status="displayText(f)"
-              />
-              <span v-else>{{ displayText(f) }}</span>
+              <StatusTag :status="displayText(f)" />
               <el-button
                 v-if="showStatusLog"
                 class="personnel-detail-descriptions__log-btn"
@@ -53,12 +49,18 @@
         </el-descriptions-item>
       </el-descriptions>
 
+      <p
+        v-if="g.key === 'status' && abnormalHint"
+        class="personnel-detail-descriptions__abnormal-hint"
+      >
+        {{ abnormalHint }}
+      </p>
       <div
         v-if="g.key === 'status' && showStatusLog && statusLogOpen"
         class="personnel-detail-descriptions__log-panel"
       >
         <p class="personnel-detail-descriptions__log-tip">
-          含业务状态(正常/暂停/停止等)与居保匹配变更;展示操作人、时间、意见、附件。
+          含业务状态、居保匹配与退养资格变更;展示操作人、时间、意见、附件。
         </p>
         <PersonStatusTimeline
           :logs="statusLogs"
@@ -83,6 +85,7 @@ import {
 } from '@/constants/personnelFields'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
 import { labelOf, personnelStatusLabel } from '@/utils/statusLabels'
+import { isPersonnelBizStatusLog, isPersonnelInsuranceLog, isPersonnelQualifyLog } from '@/utils/personnelStatusLog'
 import type { Person, ProcessLogItem } from '@/types/biz'
 
 const props = withDefaults(
@@ -102,9 +105,9 @@ const props = withDefaults(
   },
 )
 
-type LogFilter = 'all' | 'biz' | 'insurance'
+type LogFilter = 'all' | 'biz' | 'insurance' | 'qualify'
 
-const STATUS_LOG_KEYS = new Set(['insuranceStatus', 'status'])
+const STATUS_LOG_KEYS = new Set(['insuranceStatus', 'status', 'retireQualifyType'])
 
 const fields = computed(() =>
   visiblePersonnelFields().filter((f) => !f.detailHidden),
@@ -119,6 +122,16 @@ const statusLogs = ref<ProcessLogItem[]>([])
 const loadedForId = ref('')
 const logFilter = ref<LogFilter>('all')
 
+const abnormalHint = computed(() => {
+  const biz = String(props.person?.bizStatus || '').toUpperCase()
+  const ins = String(props.person?.insuranceMatchResult || '').toUpperCase()
+  if (biz !== 'ABNORMAL' && props.person?.status !== '异常待处理') return ''
+  if (ins === 'FAIL' || ins === 'LOSE_ELIGIBILITY') {
+    return '当前为异常待处理:居保未通过。请由镇社区事务中心在「居保匹配」页人工确认(通过则恢复正常发放,不通过则暂停)。修改人员信息不会自动改此状态。'
+  }
+  return '当前为异常待处理(不是独立的「待人工确认」状态)。居保已通过时,可由管理员「变更状态」恢复正常发放,或先查看告警(如土地不符、银行卡异常)。'
+})
+
 function fieldsOf(group: PersonnelFieldGroup) {
   return fields.value.filter((f) => f.group === group)
 }
@@ -148,8 +161,18 @@ function numVal(key: string): number | undefined {
   return Number.isFinite(n) ? n : undefined
 }
 
+function qualifyDisplay(raw?: string | null): string {
+  const s = String(raw || '').trim()
+  if (!s || s === '—' || s === 'UNSET' || s === 'NONE' || s === 'CLEARED') return '待认定'
+  if (s === 'WITH_LAND') return '有土地退养'
+  if (s === 'WITHOUT_LAND') return '无土地有资格'
+  if (s === 'INELIGIBLE') return '无资格'
+  return s
+}
+
 function displayText(f: PersonnelFieldDef): string {
   const raw = personField(f.key)
+  if (f.key === 'retireQualifyType') return qualifyDisplay(raw == null ? '' : String(raw))
   if (raw == null || raw === '') return '—'
   const s = String(raw)
   if (f.key === 'bizStatus') return personnelStatusLabel(s)
@@ -160,17 +183,11 @@ function displayText(f: PersonnelFieldDef): string {
 }
 
 function isInsuranceLog(log: ProcessLogItem) {
-  const t = [
-    log.action,
-    log.actionLabel,
-    log.bizType,
-    log.status,
-    log.opinion,
-    log.remarks,
-  ]
-    .filter(Boolean)
-    .join('|')
-  return /居保|INSURANCE|MATCH|匹配|比对|MANUAL_PASS|LOSE_ELIGIBILITY/i.test(t)
+  return isPersonnelInsuranceLog(log)
+}
+
+function isBizStatusLog(log: ProcessLogItem) {
+  return isPersonnelBizStatusLog(log)
 }
 
 /** 历史数据未写流程留痕时,用人员当前居保结果补一条可读记录 */
@@ -194,11 +211,65 @@ function buildInsuranceSnapshot(person: Person): ProcessLogItem | null {
   }
 }
 
+/** 无资格留痕时,用当前退养资格补一条可读记录 */
+function buildQualifySnapshot(person: Person): ProcessLogItem | null {
+  const zh = qualifyDisplay(person.retireQualifyType)
+  return {
+    id: `qualify-snap-${person.id}`,
+    action: 'RETIRE_QUALIFY_SNAPSHOT',
+    actionLabel: '退养资格(当前)',
+    bizType: 'RETIRE_QUALIFY',
+    operator: '系统',
+    operatorRole: '镇经发中心',
+    result: zh,
+    status: person.retireQualifyType || 'UNSET',
+    opinion: `当前退养资格:${zh}`,
+    operateTime: person.updateTime || person.createTime,
+    remarks: 'HIST_SNAPSHOT|RETIRE_QUALIFY',
+  }
+}
+
+/** 无业务状态留痕时,用当前人员业务状态补一条可读记录 */
+function buildBizStatusSnapshot(person: Person): ProcessLogItem | null {
+  const status = String(person.status || '').trim()
+  const biz = String(person.bizStatus || '').trim()
+  if ((!status || status === '—') && !biz) return null
+  const zh = status || personnelStatusLabel(biz) || biz
+  const bizZh = biz ? personnelStatusLabel(biz) || biz : ''
+  return {
+    id: `biz-snap-${person.id}`,
+    action: 'PERSONNEL_STATUS_SNAPSHOT',
+    actionLabel: '人员业务状态(当前)',
+    bizType: 'PERSONNEL_STATUS',
+    operator: '系统',
+    operatorRole: '业务部门',
+    result: zh,
+    status: biz || status,
+    opinion: bizZh && bizZh !== zh
+      ? `当前人员业务状态:${zh}(${bizZh})`
+      : `当前人员业务状态:${zh}`,
+    operateTime: person.updateTime || person.createTime,
+    remarks: 'HIST_SNAPSHOT|PERSONNEL_STATUS',
+  }
+}
+
 function mergeLogs(list: ProcessLogItem[], person: Person): ProcessLogItem[] {
-  const hasIns = list.some(isInsuranceLog)
-  if (hasIns) return list
-  const snap = buildInsuranceSnapshot(person)
-  return snap ? [...list, snap] : list
+  const extra: ProcessLogItem[] = []
+  if (!list.some(isInsuranceLog)) {
+    const snap = buildInsuranceSnapshot(person)
+    if (snap) extra.push(snap)
+  }
+  if (!list.some(isBizStatusLog)) {
+    const snap = buildBizStatusSnapshot(person)
+    if (snap) extra.push(snap)
+  }
+  if (!list.some(isPersonnelQualifyLog)) {
+    const snap = buildQualifySnapshot(person)
+    if (snap) extra.push(snap)
+  }
+  return [...list, ...extra].sort((a, b) =>
+    String(b.operateTime || '').localeCompare(String(a.operateTime || '')),
+  )
 }
 
 async function loadStatusLogs(force = false) {
@@ -224,6 +295,8 @@ function openStatusLog(fieldKey?: string) {
     logFilter.value = 'insurance'
   } else if (fieldKey === 'status' || fieldKey === 'bizStatus') {
     logFilter.value = 'biz'
+  } else if (fieldKey === 'retireQualifyType') {
+    logFilter.value = 'qualify'
   } else {
     logFilter.value = 'all'
   }
@@ -282,6 +355,12 @@ watch(
   height: auto;
   font-size: 12px;
 }
+.personnel-detail-descriptions__abnormal-hint {
+  margin: 0 0 10px;
+  font-size: 12px;
+  color: @text-secondary;
+  line-height: 1.5;
+}
 .personnel-detail-descriptions__log-panel {
   margin: 4px 0 12px;
   padding: 10px 12px 4px;

+ 22 - 2
src/components/biz/PersonnelFormFields.vue

@@ -71,7 +71,7 @@
               :disabled="!canEdit(f)"
               clearable
               style="width: 100%"
-              placeholder="选填"
+              :placeholder="selectPlaceholder(f)"
               @update:model-value="setField(f.key, $event || '')"
             >
               <el-option
@@ -170,6 +170,7 @@ function fieldsOf(group: PersonnelFieldGroup) {
 
 function canEdit(f: PersonnelFieldDef) {
   if (f.editable === false) return false
+  if (f.key === 'retireQualifyType') return false
   // 可研 5.1.2.4:编辑时继承人仅镇社区事务受理中心(及管理员)可改;新增录入可填
   if (HEIR_KEYS.has(f.key) && props.mode === 'edit') {
     const role = userStore.currentRole
@@ -178,6 +179,11 @@ function canEdit(f: PersonnelFieldDef) {
   return true
 }
 
+function selectPlaceholder(f: PersonnelFieldDef) {
+  if (f.key === 'retireQualifyType') return '由镇经发审批认定,录入不可选'
+  return canEdit(f) ? '选填' : '—'
+}
+
 function isRequired(key: PersonnelFieldKey) {
   return props.requiredKeys.includes(key) && canEdit(
     fields.value.find((x) => x.key === key) || { key, label: '', group: 'basic', editable: true },
@@ -232,7 +238,21 @@ function onTownUpdate(v: string) {
 .personnel-form-fields :deep(.el-divider:first-child) {
   margin-top: 0;
 }
+.personnel-form-fields :deep(.el-col) {
+  overflow: visible;
+}
 .personnel-form-fields :deep(.el-form-item) {
-  margin-bottom: 12px;
+  margin-bottom: 16px;
+  align-items: flex-start;
+}
+.personnel-form-fields :deep(.el-form-item.is-error) {
+  margin-bottom: 18px;
+}
+.personnel-form-fields :deep(.el-form-item__error) {
+  position: static;
+  padding-top: 4px;
+  line-height: 1.45;
+  white-space: normal;
+  word-break: break-word;
 }
 </style>

+ 63 - 0
src/composables/useSubmitLock.ts

@@ -0,0 +1,63 @@
+import { ElMessage } from 'element-plus'
+import type { Ref } from 'vue'
+import { SUBMIT_BUSY_MSG, SUBMIT_SLOW_MSG } from '@/constants/submitLock'
+
+export { SUBMIT_BUSY_MSG, SUBMIT_SLOW_MSG, DUPLICATE_SUBMIT } from '@/constants/submitLock'
+
+function isBusyValue(v: unknown): boolean {
+  if (v === true) return true
+  if (v === false || v == null || v === '') return false
+  return true
+}
+
+/** 已在处理中则提示并返回 true,调用方应直接 return */
+export function guardBusy(
+  lock: Ref<unknown>,
+  message = SUBMIT_BUSY_MSG,
+): boolean {
+  if (!isBusyValue(lock.value)) return false
+  ElMessage.warning(message)
+  return true
+}
+
+export function startSlowHint(ms = 8000): () => void {
+  const t = window.setTimeout(() => {
+    ElMessage.info(SUBMIT_SLOW_MSG)
+  }, ms)
+  return () => window.clearTimeout(t)
+}
+
+/** 布尔锁:同步抢锁,超时/异常也必释放 */
+export async function runSubmit<T>(
+  lock: Ref<boolean>,
+  task: () => Promise<T>,
+  opts?: { busyMessage?: string; slowMs?: number },
+): Promise<T | undefined> {
+  if (guardBusy(lock, opts?.busyMessage)) return undefined
+  lock.value = true
+  const stopSlow = startSlowHint(opts?.slowMs)
+  try {
+    return await task()
+  } finally {
+    stopSlow()
+    lock.value = false
+  }
+}
+
+/** 行级锁(按钮 :loading="id === row.id") */
+export async function runSubmitId<T>(
+  lock: Ref<string>,
+  id: string,
+  task: () => Promise<T>,
+  opts?: { busyMessage?: string; slowMs?: number },
+): Promise<T | undefined> {
+  if (guardBusy(lock, opts?.busyMessage)) return undefined
+  lock.value = id
+  const stopSlow = startSlowHint(opts?.slowMs)
+  try {
+    return await task()
+  } finally {
+    stopSlow()
+    lock.value = ''
+  }
+}

+ 1 - 1
src/constants/personnelFields.ts

@@ -163,7 +163,7 @@ export const PERSONNEL_FIELDS: PersonnelFieldDef[] = [
   { key: 'status', label: '人员业务状态', group: 'status', editable: false },
   { key: 'bizStatus', label: '人员业务状态', group: 'status', editable: false, detailHidden: true },
   { key: 'payThisMonth', label: '是否本月发放', group: 'status', editable: false, control: 'switch' },
-  { key: 'retireQualifyType', label: '退养资格类型', group: 'status', editable: true, control: 'select', options: [
+  { key: 'retireQualifyType', label: '退养资格类型', group: 'status', editable: false, control: 'select', options: [
     { label: '有土地退养', value: '有土地退养' },
     { label: '无土地有资格', value: '无土地有资格' },
     { label: '无资格', value: '无资格' },

+ 3 - 0
src/constants/submitLock.ts

@@ -0,0 +1,3 @@
+export const SUBMIT_BUSY_MSG = '正在处理,请勿重复提交'
+export const SUBMIT_SLOW_MSG = '处理时间较长,请稍候,请勿重复点击'
+export const DUPLICATE_SUBMIT = 'DUPLICATE_SUBMIT'

+ 7 - 0
src/layouts/MainLayout.vue

@@ -461,6 +461,13 @@ onMounted(async () => {
     padding: @content-padding;
     background: @page-bg;
     overflow: auto;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+
+    > *:not(.page-fill) {
+      flex-shrink: 0;
+    }
   }
 }
 </style>

+ 130 - 3
src/styles/index.less

@@ -141,11 +141,11 @@ a {
 .query-panel {
   background: @block-bg;
   border: 1px solid @border-light;
-  padding: 12px 12px 4px;
-  margin-bottom: 12px;
+  padding: 8px 10px 0;
+  margin-bottom: 8px;
 
   .el-form-item {
-    margin-bottom: 10px;
+    margin-bottom: 8px;
   }
 
   .el-form-item__label {
@@ -162,6 +162,133 @@ a {
   margin-bottom: 12px;
 }
 
+/* ========== 列表页铺满主内容区,避免表下大块留白 ========== */
+.page-fill {
+  flex: 1 1 auto;
+  min-height: 0;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+
+  > .content-panel {
+    flex: 1;
+    min-height: 0;
+    margin-bottom: 0 !important;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    padding: 10px 12px;
+  }
+
+  > .content-panel + .content-panel {
+    flex: 0 1 auto;
+    max-height: 240px;
+    overflow: auto;
+    margin-top: 8px;
+  }
+
+  > .content-panel > .el-alert,
+  > .content-panel > .el-form,
+  > .content-panel > .el-tabs {
+    flex-shrink: 0;
+  }
+
+  .page-panel__title,
+  .block-title {
+    flex-shrink: 0;
+    margin-bottom: 8px;
+    padding-bottom: 6px;
+  }
+
+  .el-tabs__header {
+    flex-shrink: 0;
+    margin-bottom: 8px;
+  }
+
+  .table-pagination {
+    flex-shrink: 0;
+    margin-top: 6px;
+    padding-top: 4px;
+  }
+
+  .query-panel {
+    flex-shrink: 0;
+    margin-bottom: 8px;
+    padding: 8px 10px 0;
+
+    .el-form-item {
+      margin-bottom: 8px;
+    }
+  }
+}
+
+/* 表格在 Tab 内:让 tabs/content/pane 一起撑满 */
+.page-fill--tabs {
+  > .content-panel > .el-tabs {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .el-tabs__content {
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .el-tab-pane {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+}
+
+.page-fill__table {
+  flex: 1;
+  min-height: 160px;
+  overflow: hidden;
+
+  > .el-table {
+    height: 100%;
+  }
+}
+
+/* 多区块页:至少铺满视口,内容超出时由主区滚动 */
+.page-stack {
+  flex: 1 0 auto;
+  min-height: calc(100vh - @header-height - @content-padding * 2);
+}
+
+.page-tip {
+  flex-shrink: 0;
+  margin: 0 0 8px;
+  font-size: 12px;
+  line-height: 1.55;
+  color: @text-secondary;
+}
+
+.page-alert {
+  flex-shrink: 0;
+  margin-bottom: 8px;
+
+  .el-alert__title {
+    font-size: 12px;
+    line-height: 1.55;
+    font-weight: 400;
+    color: @text-regular;
+  }
+
+  em {
+    font-style: normal;
+    font-weight: 600;
+    color: @primary-dark;
+  }
+}
+
 /* ========== 页面底部居中主操作 ========== */
 .page-actions {
   display: flex;

+ 8 - 0
src/types/biz.ts

@@ -148,6 +148,8 @@ export interface VillageBatch {
   village: string
   peopleCount: number
   totalAmount: number
+  parentBatchId?: string
+  batchLevel?: string
   stage: string
   submitter: string
   submittedAt?: string
@@ -165,6 +167,10 @@ export interface VillageBatch {
   townOpinion?: string
   /** 备注 */
   remarks?: string
+  /** 是否年调标差额批 */
+  yearlyAdjust?: boolean
+  /** 测算是否锁定 */
+  calcLocked?: boolean
   normalAmount?: number
   supplementAmount?: number
   deductAmount?: number
@@ -228,6 +234,8 @@ export interface VillageMonthlyBatch {
   extraAmount: number
   deductAmount: number
   totalAmount: number
+  parentBatchId?: string
+  batchLevel?: string
   /**
    * 清单状态:
    * 待提交 | 待审核(分管领导审核)| 已审核 | 已出盘 | 已回盘

+ 28 - 14
src/utils/batchAdjust.ts

@@ -7,25 +7,39 @@ export type AdjustBatchLike = {
   id?: string | null
   remarks?: string | null
   remark?: string | null
+  yearlyAdjust?: boolean | string | null
+  batchLevel?: string | null
+  village?: string | null
+}
+
+function compact(raw?: string | null): string {
+  return String(raw || '').replace(/[-_/]/g, '')
+}
+
+function hasAdjust99(raw?: string | null): boolean {
+  return /20\d{2}99/.test(compact(raw))
+}
+
+function isCalendarMonthToken(raw?: string | null): boolean {
+  const s = compact(raw)
+  return /20\d{2}(0[1-9]|1[0-2])/.test(s) && !/20\d{2}99/.test(s)
 }
 
 /**
- * 是否年调标差额批:业务号/发放月以 99 结尾,或备注含调标标记。
- * 例:202699、VB202699-xxx、备注 YEARLY_ADJUST|…
+ * 是否年调标差额专批:业务号/发放月含 20xx99
+ * 全区月批、镇统计即使备注写了 YEARLY_ADJUST,也按日常发放批处理。
  */
 export function isYearlyAdjustBatchLike(b?: AdjustBatchLike | null): boolean {
   if (!b) return false
-  const remarks = String(b.remarks || b.remark || '')
-  if (remarks.includes('YEARLY_ADJUST') || remarks.includes('调标')) return true
-  const candidates = [b.batchNo, b.month, b.payMonth, b.id]
-  for (const raw of candidates) {
-    const s = String(raw || '').trim()
-    if (!s) continue
-    // 纯业务月 20xx99
-    if (/^20\d{2}99$/.test(s)) return true
-    // 去分隔后以 99 结尾且长度足够(如 VB202699叶榭)
-    const compact = s.replace(/[-_/]/g, '')
-    if (compact.length >= 6 && /20\d{2}99/.test(compact)) return true
+  const level = String(b.batchLevel || '').toUpperCase()
+  if (level === 'DISTRICT' || level === 'TOWN' || b.village === '全区') return false
+  if (
+    isCalendarMonthToken(b.payMonth) ||
+    isCalendarMonthToken(b.month) ||
+    isCalendarMonthToken(b.batchNo)
+  ) {
+    return false
   }
-  return false
+  if (hasAdjust99(b.batchNo) || hasAdjust99(b.month) || hasAdjust99(b.payMonth)) return true
+  return b.yearlyAdjust === true || b.yearlyAdjust === 'true'
 }

+ 109 - 56
src/utils/buildDashboard.ts

@@ -1,12 +1,11 @@
 /**
- * 工作台数据按角色构建(对齐操作流程图线上五段
+ * 工作台数据按角色构建(对齐现行:资格审批 + 每月 1 号全区月批
  *
  * 角色差异:
  * - 数据范围:allData=全区;有 village=本村;仅有 town=本镇
- * - 柱状图维度:全区按街镇;村级按批次;镇经发=本批次各村金额/人数
- * - 镇经发中心工作台:土地退养资格初审(有土地 / 无土地有资格)+ 各村发放统计
- * - 镇社区事务中心工作台:居保匹配与发放办理 + 各村发放统计 + 业务办理要点
- * - 其他角色待办:按角色过滤贴近各自节点
+ * - 村合作社 / 镇经发:不看见批次,只维护人员与退养资格
+ * - 镇社区事务:居保、月度清单、特殊业务、出盘回盘(全区月批本镇切片)
+ * - 分管领导:审本镇月度清单(镇统计)与特殊业务
  */
 import type { RoleItem } from '@/utils/appConfig'
 import type {
@@ -33,10 +32,10 @@ import {
 /** 线上主流程(不含镇领导签字、财务发放) */
 export const FLOW_STAGES = [
   '人员新增录入',
-  '镇经发中心初审',
+  '退养资格审批',
   '居保状态匹配',
   '分管领导审批',
-  '待线下发放',
+  '出盘发放',
   '回盘信息确认',
 ]
 
@@ -44,88 +43,149 @@ export const FLOW_STAGES = [
 export const VILLAGE_WORKBENCH_TIPS = [
   {
     title: '人员新增录入',
-    desc: '仅限本村人员录入,完成信息填写后提交镇经发中心初审。',
+    desc: '仅本村单人录入或 Excel 导入。保存后为「待提交资格」,本页不展示批次、不提交村批。',
   },
   {
-    title: '退回修改重提',
-    desc: '被退回修改的批次请核验人员信息后重新提交,勿跨村录入。',
+    title: '提交资格',
+    desc: '材料齐全后点「提交资格」,交镇经发按人审批。通过后为「新增」,等待下个 1 号纳入全区月发。',
   },
   {
-    title: '监控告警处理',
-    desc: '核实人员信息并可登记继承人,处理后与镇社区事务中心状态同步。',
+    title: '驳回后重提',
+    desc: '镇经发驳回补材料后回到「待提交资格」,核验身份证、银行卡、退养材料后再提交。认定无资格则进入异常待处理。',
   },
   {
-    title: '登记码填写',
-    desc: '社会保险登记码建议在录入时一并填写,便于后续居保状态匹配。',
+    title: '每月 1 号纳入',
+    desc: '1 号系统生成全区月批。「新增」与正常发放人员自动进当月。1 号后新录入默认进下月;本月要发须由镇社区事务走特殊业务。',
   },
   {
-    title: '关键信息核对',
-    desc: '身份证号、银行卡号请仔细核对,避免回盘发放失败产生告警。',
+    title: '告警与关键信息',
+    desc: '身份证、银行卡、社保登记码请一次填准。本村告警与镇社区事务共用,任一方处理后即时同步。',
   },
 ]
 
 export const TOWN_REVIEW_TIPS = [
   {
-    title: '核验土地退养情况',
-    desc: '对照村级申报材料,确认新增人员是否确有土地退养事实,重点核对承包地、退养协议等要件。',
+    title: '按人审资格',
+    desc: '审本镇各村提交的退养资格(无批次)。通过时选择「有土地退养」或「无土地有资格」。',
   },
   {
-    title: '无土地有资格情形',
-    desc: '对无承包地但按规定仍可享受退养补助的人员,核验政策依据与证明材料,备注说明资格来源。',
+    title: '核验土地退养',
+    desc: '对照村级材料确认确有土地退养事实,重点核对承包地、退养协议等要件。',
   },
   {
-    title: '按村逐户初审',
-    desc: '本镇各村新增人员统一纳入当前批次初审;材料不全或存疑可驳回村合作社补充修改后重提。',
+    title: '无土地有资格',
+    desc: '无承包地但按规定仍可享受的,核验政策依据与证明,并在审批意见中说明资格来源。',
   },
   {
-    title: '通过后流转',
-    desc: '初审通过后进入居保状态匹配;驳回后批次退回村合作社,勿跨村、跨批次混批处理。',
+    title: '驳回与无资格',
+    desc: '材料不全驳回补材料(退回「待提交资格」);认定无资格则人员进入异常待处理。',
+  },
+  {
+    title: '职责边界',
+    desc: '通过后人员为「新增」,等每月 1 号纳入全区月发。不办理居保、月度清单、出盘回盘。',
   },
 ]
 
 export const SOCIAL_WORKBENCH_TIPS = [
   {
-    title: '居保状态匹配',
-    desc: '对本镇已初审通过批次触发居保交叉核验,产出比对结果清单;异常批次可退回镇经发中心。',
+    title: '每月 1 号全区月批',
+    desc: '系统生成当月一条全区批次并自动居保。本中心只办本镇切片,看不见其他镇明细。',
   },
   {
-    title: '特殊业务办理',
-    desc: '死亡录入、丧葬补贴、暂停/恢复及调标补发等特殊业务在本中心受理并流转审批。',
+    title: '居保核验',
+    desc: '在「居保状态匹配」核验本镇人员。失败须本页人工确认后,才能提交月度清单。',
   },
   {
-    title: '提交分管领导',
-    desc: '比对结果确认无误后提交分管领导线上审批;材料不全或存疑勿提交。',
+    title: '特殊业务门禁',
+    desc: '死亡、丧葬、停发/恢复、调标补发、多发退款须分管领导通过。未全部通过前,不可提交本镇月度清单。1 号后新人要进本月发放,也须经特殊业务加人。',
   },
   {
-    title: '出盘与回盘',
-    desc: '出盘即生成月补贴清单;确认回盘信息后完成发放闭环,失败记录需纳入下月补发。',
+    title: '清单、出盘、回盘',
+    desc: '居保完成且特殊业务齐后,提交本镇清单给分管领导;通过后出盘。回盘确认后点「归档」完成本月闭环。',
   },
   {
-    title: '监控告警处理',
-    desc: '及时处理居保匹配失败、回盘异常等告警,与村合作社、镇经发中心保持状态同步。',
+    title: '监控告警',
+    desc: '及时处理居保失败、回盘失败、死亡比对等告警,与对应村合作社共用同一告警池。',
   },
 ]
 
 export const LEADER_WORKBENCH_TIPS = [
   {
-    title: '月补贴清单审批',
-    desc: '审阅镇社区事务受理中心提交的本镇月补贴批次;通过后进入出盘环节,由社区事务中心办理。',
+    title: '月清单审批',
+    desc: '审镇社区事务提交的本镇月度清单(镇统计,挂全区月批)。通过后社区事务可出盘。',
   },
   {
     title: '特殊业务审批',
-    desc: '审阅丧葬补贴、暂停/恢复、调标补发等特殊业务申请,材料不全或存疑应驳回补充。',
+    desc: '审死亡/丧葬、停发恢复、调标补发、多发退款等。材料不全或存疑应驳回补充。',
   },
   {
-    title: '驳回处理',
-    desc: '月度批次驳回后退回居保匹配;特殊业务驳回后退回业务受理补充/修改。',
+    title: '提交顺序',
+    desc: '社区事务须在本镇当月特殊业务全部通过后,才能把月度清单提交到本节点。',
   },
   {
-    title: '数据查询统计',
-    desc: '可按本镇各村查看批次与人员台账及金额类数据,便于审批前核对规模与异常。',
+    title: '驳回去向',
+    desc: '清单驳回后退回社区事务继续办理;特殊业务驳回后退回业务受理补充/修改。',
   },
   {
     title: '审批边界',
-    desc: '您为社区事务中心上级领导,主要审批其提交的批次,不直接办理出盘、回盘等经办事项。',
+    desc: '只审本镇清单与特殊业务,不直接办理居保、出盘、回盘。可用统计页核对规模与异常。',
+  },
+]
+
+export const AGRI_WORKBENCH_TIPS = [
+  {
+    title: '监测范围',
+    desc: '全区各镇各村土地退养政策落实情况(只读),侧重有土地 / 无土地有资格结构。',
+  },
+  {
+    title: '资格主线',
+    desc: '村合作社录入并提交资格 → 镇经发按人审批 →「新增」等待每月 1 号纳入全区月发。',
+  },
+  {
+    title: '不办理发放',
+    desc: '不审批月度清单、不做出盘回盘。发放由镇社区事务办理、分管领导审批。',
+  },
+  {
+    title: '数据查询',
+    desc: '在「数据查询统计」按街镇查看资格人数与专题报表,用于政策排查。',
+  },
+]
+
+export const HR_WORKBENCH_TIPS = [
+  {
+    title: '监测范围',
+    desc: '全区各镇发放与居保相关指标(只读),可查看告警但不可关闭。',
+  },
+  {
+    title: '发放主线',
+    desc: '每月 1 号全区一批次并居保 → 镇社区事务核验清单 → 分管领导审批 → 出盘回盘归档。',
+  },
+  {
+    title: '关注异常',
+    desc: '居保失败、回盘发放失败、死亡比对等告警可在「监控告警」查阅,由镇社区事务或村处理。',
+  },
+  {
+    title: '数据查询',
+    desc: '在「数据查询统计」查看各镇应发金额、区/镇分担及发放异常专题。',
+  },
+]
+
+export const ADMIN_WORKBENCH_TIPS = [
+  {
+    title: '全区月批运维',
+    desc: '每月 1 号 01:00 自动生成 DB{yyyyMM} 并居保。若当日已过 1 号,可在「批次管理」手动生成/重跑。',
+  },
+  {
+    title: '不替代业务审批',
+    desc: '资格仍由村提交、镇经发按人审;发放仍由各镇社区事务与分管领导办理。管理员按街镇切入协查。',
+  },
+  {
+    title: '人员与区划',
+    desc: '人员底数、行政区划、补助标准可全区维护。日常新增仍建议在「人员新增录入」按村办理。',
+  },
+  {
+    title: '账号与数据',
+    desc: '「系统管理」维护账号与校验规则。「恢复初始化」会清空批次/明细/资格审批日志等,人员资格打回待提交,请谨慎。',
   },
 ]
 
@@ -151,12 +211,10 @@ export function getDashboardScopeLabel(role: RoleItem): string {
 
 /** 柱状图标题(随角色) */
 export function getBarChartTitle(role: RoleItem): string {
-  if (role.allData) return '本批次各街镇发放金额'
-  if (role.village) return '本村近半年各批次发放金额'
-  if (role.key === 'town' || (role.town && !role.village && !role.allData)) {
-    return '本批次各村发放金额'
-  }
-  return '本批次本镇各村发放金额'
+  if (role.allData) return '本月各街镇发放金额'
+  if (role.village) return '本村近半年发放金额'
+  if (role.key === 'town') return '本镇各村人数'
+  return '本月本镇各村发放金额'
 }
 
 /** 是否镇经发中心视角(有街镇、无村居、非全区) */
@@ -278,6 +336,7 @@ function isTownPendingPerson(p: Person): boolean {
     p.status === '草稿' ||
     p.status === '人员新增录入' ||
     p.status === '退回修改' ||
+    p.status === '异常待处理' ||
     p.status === '待人工确认' ||
     p.status.includes('待审')
   )
@@ -515,14 +574,8 @@ function buildTodos(
   const fromBatches = scopeBatches
     .filter((b) => FLOW_STAGES.some((s) => b.stage.includes(s) || b.stage === '退回修改' || b.stage === '比对结果已生成'))
     .filter((b) => {
-      if (role.key === 'village') {
-        return (
-          b.stage === '人员新增录入' ||
-          b.stage === '退回修改' ||
-          b.stage.includes('录入')
-        )
-      }
-      if (role.key === 'town') return b.stage.includes('镇经发')
+      if (role.key === 'village') return false
+      if (role.key === 'town') return false
       if (role.key === 'social') {
         return (
           b.stage.includes('居保') ||

+ 41 - 0
src/utils/personnelStatusLog.ts

@@ -0,0 +1,41 @@
+import type { ProcessLogItem } from '@/types/biz'
+
+function logText(log: ProcessLogItem) {
+  return [
+    log.action,
+    log.actionLabel,
+    log.bizType,
+    log.status,
+    log.opinion,
+    log.remarks,
+    log.result,
+  ]
+    .filter(Boolean)
+    .join('|')
+}
+
+export function isPersonnelInsuranceLog(log: ProcessLogItem) {
+  return /居保|INSURANCE|MATCH|匹配|比对|MANUAL_PASS|LOSE_ELIGIBILITY/i.test(logText(log))
+}
+
+export function isPersonnelQualifyLog(log: ProcessLogItem) {
+  return /RETIRE_QUALIFY|QUALIFY_SUBMIT|QUALIFY_PASS|QUALIFY_REJECT|退养资格|资格审批|资格提交|资格→/i.test(
+    logText(log),
+  )
+}
+
+/**
+ * 人员业务状态相关留痕。
+ * 人员业务状态相关留痕(含异常待处理 / 居保失败导致的人工确认)。
+ */
+export function isPersonnelBizStatusLog(log: ProcessLogItem) {
+  const t = logText(log)
+  if (
+    /PERSONNEL_STATUS|STATUS_|暂停|停止|恢复|PAUSE|RESUME|STOPPED|NORMAL|死亡|丧葬|归档|DRAFT|业务状态|发放|ABNORMAL|异常待处理|待人工确认|PENDING_INSURANCE|SPECIAL_BIZ/i.test(
+      t,
+    )
+  ) {
+    return true
+  }
+  return /FAIL|LOSE_ELIGIBILITY|人工确认|待确认/i.test(t) && isPersonnelInsuranceLog(log)
+}

+ 134 - 3
src/utils/request.ts

@@ -6,6 +6,11 @@ import axios, {
 } from 'axios'
 import { ElMessage } from 'element-plus'
 import { getAppConfig } from '@/utils/appConfig'
+import {
+  DUPLICATE_SUBMIT,
+  SUBMIT_BUSY_MSG,
+  SUBMIT_SLOW_MSG,
+} from '@/constants/submitLock'
 
 /** 统一响应结构,可按后端实际字段调整 */
 export interface ApiResponse<T = unknown> {
@@ -16,12 +21,26 @@ export interface ApiResponse<T = unknown> {
 }
 
 /** 扩展:silent 时网络/业务失败不弹 ElMessage(供可降级接口) */
-export type RequestConfig = AxiosRequestConfig & { silent?: boolean; skipCache?: boolean }
+export type RequestConfig = AxiosRequestConfig & {
+  silent?: boolean
+  skipCache?: boolean
+  /** 跳过写操作防重复提交锁(分页查询等) */
+  skipSubmitLock?: boolean
+}
+
+type SubmitAwareConfig = RequestConfig & {
+  __submitLockKey?: string
+  __slowTimer?: number
+  __lockExpireTimer?: number
+}
+
+const inflightMutations = new Set<string>()
 
 const service: AxiosInstance = axios.create({
   // 联调默认走 Vite 代理 /api → sjnmtybt-server;亦可在 config.js 配 apiBase
   baseURL: getAppConfig().apiBase || '/api',
   timeout: getAppConfig().requestTimeout || 15000,
+  timeoutErrorMessage: '请求超时,请稍后重试',
 })
 
 /** 短时 GET 缓存:工作台/区划等只读接口,避免切换角色重复打满 */
@@ -37,6 +56,90 @@ function shouldCacheGet(url: string) {
   return GET_CACHE_PATHS.some((p) => url === p || url.startsWith(p + '?'))
 }
 
+function isWriteMethod(method?: string) {
+  const m = (method || 'get').toUpperCase()
+  return m === 'POST' || m === 'PUT' || m === 'PATCH' || m === 'DELETE'
+}
+
+function bodyKey(data: unknown): string {
+  if (data == null) return ''
+  if (typeof FormData !== 'undefined' && data instanceof FormData) {
+    const parts: string[] = []
+    data.forEach((v, k) => {
+      if (typeof File !== 'undefined' && v instanceof File) {
+        parts.push(`${k}=file:${v.name}:${v.size}`)
+      } else {
+        parts.push(`${k}=${String(v)}`)
+      }
+    })
+    return parts.join('&')
+  }
+  try {
+    return JSON.stringify(data)
+  } catch {
+    return String(data)
+  }
+}
+
+function mutationKey(config: AxiosRequestConfig): string {
+  const method = (config.method || 'post').toUpperCase()
+  const url = String(config.url || '').split('?')[0]
+  return `${method}:${url}:${bodyKey(config.data)}`
+}
+
+/** 列表/分页常用 POST,不参与写操作锁 */
+function isQueryLikeUrl(url: string) {
+  const u = url.split('?')[0]
+  if (/\/page$|\/list$|\/results$|\/task-logs$|\/overview$|\/progress$/.test(u)) return true
+  if (u.includes('/dashboard/')) return true
+  if (u === '/regions' || u.startsWith('/regions/')) return true
+  return false
+}
+
+function shouldLockSubmit(config: RequestConfig): boolean {
+  if (config.skipSubmitLock) return false
+  if (!isWriteMethod(config.method)) return false
+  return !isQueryLikeUrl(String(config.url || ''))
+}
+
+function releaseSubmitLock(config?: SubmitAwareConfig) {
+  if (!config) return
+  const key = config.__submitLockKey
+  if (key) inflightMutations.delete(key)
+  if (config.__slowTimer) window.clearTimeout(config.__slowTimer)
+  if (config.__lockExpireTimer) window.clearTimeout(config.__lockExpireTimer)
+  config.__submitLockKey = undefined
+  config.__slowTimer = undefined
+  config.__lockExpireTimer = undefined
+}
+
+function axiosErrorMessage(error: {
+  code?: string
+  message?: string
+  response?: { data?: { message?: string; msg?: string } }
+}): string {
+  if (error?.code === DUPLICATE_SUBMIT || error?.message === DUPLICATE_SUBMIT) {
+    return ''
+  }
+  if (
+    error?.code === 'ECONNABORTED' ||
+    error?.code === 'ETIMEDOUT' ||
+    error?.code === 'ERR_CANCELED' ||
+    /timeout/i.test(error?.message || '')
+  ) {
+    return '请求超时,请稍后重试'
+  }
+  if (error?.code === 'ERR_NETWORK' || error?.message === 'Network Error') {
+    return '网络异常或服务无响应,请稍后重试'
+  }
+  return (
+    error.response?.data?.message ||
+    error.response?.data?.msg ||
+    error.message ||
+    '网络异常'
+  )
+}
+
 // 请求拦截
 service.interceptors.request.use(
   (config: InternalAxiosRequestConfig) => {
@@ -56,6 +159,25 @@ service.interceptors.request.use(
         delete (config.headers as Record<string, unknown>)['Content-Type']
       }
     }
+    const req = config as InternalAxiosRequestConfig & SubmitAwareConfig
+    if (shouldLockSubmit(req)) {
+      const key = mutationKey(req)
+      if (inflightMutations.has(key)) {
+        ElMessage.warning(SUBMIT_BUSY_MSG)
+        const err = new Error(DUPLICATE_SUBMIT) as Error & { code: string }
+        err.code = DUPLICATE_SUBMIT
+        return Promise.reject(err)
+      }
+      inflightMutations.add(key)
+      req.__submitLockKey = key
+      req.__slowTimer = window.setTimeout(() => {
+        ElMessage.info(SUBMIT_SLOW_MSG)
+      }, 8000)
+      const ttl = Number(req.timeout || getAppConfig().requestTimeout || 15000) + 3000
+      req.__lockExpireTimer = window.setTimeout(() => {
+        inflightMutations.delete(key)
+      }, ttl)
+    }
     return config
   },
   (error) => Promise.reject(error),
@@ -64,6 +186,7 @@ service.interceptors.request.use(
 // 响应拦截
 service.interceptors.response.use(
   (response: AxiosResponse<ApiResponse>) => {
+    releaseSubmitLock(response.config)
     const res = response.data
     // 无业务 code 时直接返回(如文件流)
     if (res === undefined || res === null || typeof res !== 'object' || !('code' in res)) {
@@ -79,10 +202,14 @@ service.interceptors.response.use(
     return Promise.reject(new Error(res.message || res.msg || '请求失败'))
   },
   (error) => {
+    releaseSubmitLock(error?.config)
+    if (error?.code === DUPLICATE_SUBMIT || error?.message === DUPLICATE_SUBMIT) {
+      return Promise.reject(error)
+    }
     const silent = Boolean((error.config as RequestConfig | undefined)?.silent)
     if (!silent) {
-      const msg = error.response?.data?.message || error.message || '网络异常'
-      ElMessage.error(msg)
+      const msg = axiosErrorMessage(error)
+      if (msg) ElMessage.error(msg)
     }
     return Promise.reject(error)
   },
@@ -122,6 +249,10 @@ function request<T = unknown>(config: RequestConfig): Promise<T> {
   })
 }
 
+export function clearGetCache() {
+  getCache.clear()
+}
+
 export function get<T = unknown>(url: string, params?: object, config?: RequestConfig) {
   if (shouldCacheGet(url) && !config?.skipCache) {
     const key = cacheKeyOf(url, params)

+ 15 - 2
src/utils/statusLabels.ts

@@ -50,8 +50,9 @@ const FLOW_STAGE: Record<string, string> = {
 }
 
 const PERSONNEL_STATUS: Record<string, string> = {
-  DRAFT: '草稿',
-  PENDING_TOWN_APPROVE: '待镇初审',
+  DRAFT: '待提交资格',
+  PENDING_TOWN_APPROVE: '待资格审批',
+  NEW: '新增',
   PENDING_INSURANCE_MATCH: '待居保匹配',
   NORMAL: '正常发放',
   PAUSED: '暂停',
@@ -87,6 +88,7 @@ const ALERT_TYPE: Record<string, string> = {
   LAND_MATCH_FAIL: '土地匹配不符合',
   APPROVAL_TIMEOUT: '审批超时',
   REFUND_FISCAL: '多发退款待退缴',
+  QUALIFY_REJECT: '退养资格驳回',
   民政死亡比对: '死亡停发预警',
   回盘发放失败: '回盘失败',
 }
@@ -109,6 +111,7 @@ const SPECIAL_BIZ: Record<string, string> = {
   STANDARD_CHANGE: '标准变更',
   INSURANCE_MATCH: '居保匹配',
   PERSONNEL_STATUS: '人员状态变更',
+  RETIRE_QUALIFY: '退养资格审批',
 }
 
 /** 流程动作 / bizType 英文码 → 中文(禁止直接渲染 code) */
@@ -121,6 +124,16 @@ const PROCESS_ACTION: Record<string, string> = {
   STATUS_NORMAL: '恢复发放',
   STATUS_FUNERAL: '死亡丧葬状态落地',
   STATUS_ARCHIVED: '人员归档',
+  STATUS_ABNORMAL: '异常待处理',
+  STATUS_PENDING_INSURANCE_MATCH: '待居保匹配',
+  QUALIFY_SUBMIT: '提交退养资格',
+  QUALIFY_PASS: '退养资格审批通过',
+  QUALIFY_REJECT: '退养资格审批驳回',
+  RETIRE_QUALIFY: '退养资格审批',
+  RETIRE_QUALIFY_SNAPSHOT: '退养资格',
+  STATUS_NEW: '新增',
+  PERSONNEL_STATUS: '人员状态变更',
+  PERSONNEL_STATUS_SNAPSHOT: '人员业务状态',
   FUNERAL: '丧葬/死亡登记',
   STOP: '停止发放申请',
   PAUSE: '暂停发放申请',

+ 4 - 1
src/utils/statusTag.ts

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

+ 52 - 4
src/utils/validators.ts

@@ -1,8 +1,54 @@
 /** 人员录入 / 表单异常输入常用校验 */
 
-/** 18 位身份证(含末位 X) */
+/** 18 位身份证格式(不含校验位;完整校验请用 explainIdCard / isValidIdCard) */
 export const ID_CARD_PATTERN = /^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
 
+/** GB 11643-1999 校验位权重 */
+const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
+const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
+
+function isValidBirthYmd(ymd: string): boolean {
+  if (!/^\d{8}$/.test(ymd)) return false
+  const y = Number(ymd.slice(0, 4))
+  const m = Number(ymd.slice(4, 6))
+  const d = Number(ymd.slice(6, 8))
+  if (y < 1900 || y > 2100) return false
+  const dt = new Date(Date.UTC(y, m - 1, d))
+  return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d
+}
+
+/**
+ * 身份证失败原因(与后台 IdCardValidator 一致)。通过则返回 null。
+ * 15 位:纯数字 + 出生日期;18 位:前 17 位数字、末位校验码。
+ */
+export function explainIdCard(value?: string, label = '身份证号'): string | null {
+  const s = String(value || '').trim().toUpperCase()
+  if (!s) return `${label}不能为空`
+  if (s.length !== 15 && s.length !== 18) {
+    return `${label}须为15位或18位,当前为${s.length}位`
+  }
+  if (s.length === 15) {
+    if (!/^\d{15}$/.test(s)) return `${label}为15位时须全部为数字`
+    const yymmdd = s.slice(6, 12)
+    if (!isValidBirthYmd(`19${yymmdd}`)) {
+      return `${label}中的出生日期不合法(${yymmdd})`
+    }
+    return null
+  }
+  if (!/^\d{17}/.test(s)) return `${label}前17位须为数字`
+  const last = s.charAt(17)
+  if (!/[0-9X]/.test(last)) return `${label}末位须为数字或X`
+  const birth = s.slice(6, 14)
+  if (!isValidBirthYmd(birth)) return `${label}中的出生日期不合法(${birth})`
+  let sum = 0
+  for (let i = 0; i < 17; i++) sum += Number(s.charAt(i)) * ID_WEIGHTS[i]
+  const expect = ID_CHECK_CODES[sum % 11]
+  if (last !== expect) {
+    return `${label}末位校验码不正确(当前为${last},应为${expect})`
+  }
+  return null
+}
+
 /** 大陆手机号 */
 export const PHONE_PATTERN = /^1[3-9]\d{9}$/
 
@@ -12,7 +58,8 @@ export const PAY_MONTH_DASH_PATTERN = /^(20\d{2})-(0[1-9]|1[0-2])$/
 
 export function isValidIdCard(value?: string): boolean {
   const s = String(value || '').trim()
-  return ID_CARD_PATTERN.test(s)
+  if (!s) return false
+  return explainIdCard(s) === null
 }
 
 export function isValidPhone(value?: string): boolean {
@@ -31,7 +78,7 @@ export function isValidPhoneOptional(value?: string): boolean {
 export function isValidIdCardOptional(value?: string): boolean {
   const s = String(value || '').trim()
   if (!s) return true
-  return ID_CARD_PATTERN.test(s)
+  return explainIdCard(s) === null
 }
 
 /** 发放年月:支持 202608 / 2026-08;空值视为未填(不通过) */
@@ -96,7 +143,8 @@ export function ruleIdCard(required = true) {
         else cb()
         return
       }
-      if (!isValidIdCard(s)) cb(new Error('身份证号格式不正确'))
+      const reason = explainIdCard(s)
+      if (reason) cb(new Error(reason))
       else cb()
     },
     trigger: ['blur', 'change'] as const,

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

@@ -1,8 +1,16 @@
 <template>
-  <div class="admin-batches-view">
+  <div class="page-fill admin-batches-view">
     <PagePanel title="批次管理">
-      <p class="tip">
-        全区批次运维查询。「已归档」多为重复/作废残留。删除会摘离明细并从列表隐藏。日常业务仍由各岗位办理。
+      <template #extra>
+        <el-button type="warning" size="small" :loading="generating" @click="onGenerateDistrict(false)">
+          生成/重跑本月全区批次
+        </el-button>
+        <el-button type="danger" size="small" :loading="generating" @click="onGenerateDistrict(true)">
+          联调:按1号纳入新增
+        </el-button>
+      </template>
+      <p class="page-tip">
+        每月 1 号生成一份全区月批,各镇是统计切片(不是一村一批)。村/镇角色在各自页面按范围看人,不单独建村级批次。联调测本月发放请点「按1号纳入新增」。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -18,19 +26,14 @@
             />
           </el-form-item>
           <el-form-item label="街镇">
-            <el-select v-model="q.townId" clearable style="width: 140px" @change="onTown">
+            <el-select v-model="q.townId" clearable style="width: 140px">
               <el-option v-for="t in towns" :key="t.id" :label="t.name" :value="t.id" />
             </el-select>
           </el-form-item>
-          <el-form-item label="村居">
-            <el-select v-model="q.villageId" clearable style="width: 140px">
-              <el-option v-for="v in villages" :key="v.id" :label="v.name" :value="v.id" />
-            </el-select>
-          </el-form-item>
           <el-form-item label="级别">
             <el-select v-model="q.batchLevel" clearable style="width: 110px">
-              <el-option label="村级" value="VILLAGE" />
-              <el-option label="镇" value="TOWN" />
+              <el-option label="全区" value="DISTRICT" />
+              <el-option label="镇统计" value="TOWN" />
             </el-select>
           </el-form-item>
           <el-form-item>
@@ -39,7 +42,8 @@
           </el-form-item>
         </el-form>
       </div>
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
         <el-table-column prop="batchNo" label="统一批次号" min-width="120" show-overflow-tooltip />
         <el-table-column label="发放年月" width="100" align="center">
           <template #default="{ row }">
@@ -47,10 +51,9 @@
           </template>
         </el-table-column>
         <el-table-column label="级别" width="70" align="center">
-          <template #default="{ row }">{{ row.batchLevel === 'TOWN' ? '镇' : '村' }}</template>
+          <template #default="{ row }">{{ levelLabel((row as AdminBatch).batchLevel) }}</template>
         </el-table-column>
         <el-table-column prop="town" label="街镇" width="100" />
-        <el-table-column prop="village" label="村居" width="100" />
         <el-table-column prop="peopleCount" label="人数" width="70" align="center" />
         <el-table-column label="合计" width="110" align="right">
           <template #default="{ row }">{{ formatAmt(row.totalAmount) }}</template>
@@ -94,6 +97,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -111,9 +115,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
-import { listRegionsAdmin, pageAdminBatches, purgeBatchAdmin } from '@/api/biz'
+import { generateDistrictMonthBatch, listRegionsAdmin, pageAdminBatches, purgeBatchAdmin } from '@/api/biz'
 import StatusTag from '@/components/common/StatusTag.vue'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { runSubmit } from '@/composables/useSubmitLock'
 import { batchStatusLabel, flowStageLabelOf } from '@/utils/statusLabels'
 import type { RegionRow } from '@/api/sjnmtybt/regionStore'
 
@@ -143,28 +148,26 @@ function batchLabel(row: AdminBatch) {
 const q = reactive({
   payMonth: '',
   townId: '',
-  villageId: '',
   batchLevel: '',
 })
 const list = ref<AdminBatch[]>([])
 const towns = ref<RegionRow[]>([])
-const villages = ref<RegionRow[]>([])
 const loading = ref(false)
+const generating = ref(false)
 const drawer = ref(false)
 const drawerId = ref('')
 const drawerTab = ref<'detail' | 'flow'>('detail')
 
 const { page, pageSize, total, pagedList, resetPage } = useClientPagination(list)
 
-function formatAmt(n: number) {
-  return Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+function levelLabel(level?: string) {
+  if (level === 'DISTRICT') return '全区'
+  if (level === 'TOWN') return '镇统计'
+  return level || '—'
 }
 
-async function onTown() {
-  q.villageId = ''
-  villages.value = q.townId
-    ? await listRegionsAdmin({ level: 'VILLAGE', parentId: q.townId })
-    : []
+function formatAmt(n: number) {
+  return Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
 }
 
 async function search() {
@@ -173,7 +176,6 @@ async function search() {
     list.value = (await pageAdminBatches({
       payMonth: q.payMonth || undefined,
       townId: q.townId || undefined,
-      villageId: q.villageId || undefined,
       batchLevel: q.batchLevel || undefined,
     })) as AdminBatch[]
     resetPage()
@@ -185,12 +187,44 @@ async function search() {
 function reset() {
   q.payMonth = ''
   q.townId = ''
-  q.villageId = ''
   q.batchLevel = ''
-  villages.value = []
   search()
 }
 
+async function onGenerateDistrict(simulateFirstDay = false) {
+  try {
+    await ElMessageBox.confirm(
+      simulateFirstDay
+        ? '联调模拟每月 1 号:将把「新增」且已有资格人员的下次发放起始月改到本月,再按正式规则生成全区批并居保。正式 1 号规则不变。'
+        : '确认生成或重跑本月全区批次?将按规则纳入人员、刷新各镇统计并触发居保匹配。已有明细不会重复加倍补发/扣款。',
+      simulateFirstDay ? '联调:按1号纳入新增' : '生成全区月批',
+      { type: 'warning', confirmButtonText: simulateFirstDay ? '确认纳入并生成' : '确认生成' },
+    )
+  } catch {
+    return
+  }
+  await runSubmit(generating, async () => {
+    const res = (await generateDistrictMonthBatch(undefined, true, simulateFirstDay)) as Record<
+      string,
+      unknown
+    >
+    const n = Number(res?.peopleCount || 0)
+    const pulled = Number(res?.newPulled || 0)
+    if (n <= 0) {
+      ElMessage.warning(
+        String(res?.message || '本月无人可纳入发放。资格新通过的「新增」人员等下月 1 号。'),
+      )
+    } else {
+      ElMessage.success(
+        `全区批次已生成(${String(res?.districtBatchId || res?.payMonth || '本月')},纳入 ${n} 人`
+          + (pulled > 0 ? `,其中联调拉入新增 ${pulled} 人` : '')
+          + ')',
+      )
+    }
+    await search()
+  })
+}
+
 function open(row: AdminBatch, tab: 'detail' | 'flow') {
   drawerId.value = row.id
   drawerTab.value = tab
@@ -207,9 +241,11 @@ async function onDelete(row: AdminBatch) {
   } catch {
     return
   }
-  await purgeBatchAdmin(row.id)
-  ElMessage.success('批次已删除')
-  await search()
+  await runSubmit(loading, async () => {
+    await purgeBatchAdmin(row.id)
+    ElMessage.success('批次已删除')
+    await search()
+  })
 }
 
 onMounted(async () => {
@@ -219,12 +255,6 @@ onMounted(async () => {
 </script>
 
 <style scoped lang="less">
-.tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
 .query-panel {
   margin-bottom: 10px;
 }

+ 252 - 22
src/views/admin/PersonnelAdminView.vue

@@ -1,8 +1,22 @@
 <template>
-  <div class="personnel-admin-view">
+  <div class="page-fill personnel-admin-view">
     <PagePanel title="人员底数管理">
-      <p class="tip">
-        全区人员底数查询与运维。可修改基础信息、变更业务状态、删除(归档)。日常新增录入仍建议在「人员新增录入」按村办理。
+      <template #extra>
+        <el-button type="warning" size="small" :loading="initing" @click="openInit">
+          初始化状态
+        </el-button>
+        <el-button
+          type="danger"
+          size="small"
+          :disabled="selected.length === 0"
+          :loading="batchDeleting"
+          @click="onBatchDelete"
+        >
+          批量删除{{ selected.length ? `(${selected.length})` : '' }}
+        </el-button>
+      </template>
+      <p class="page-tip">
+        全区人员底数运维。可改基础信息、变更状态、删除(归档)。「初始化状态」可一键改全员(或当前筛选街镇/村)的业务状态、居保匹配与退养资格,不改姓名银行卡。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -25,7 +39,19 @@
           </el-form-item>
         </el-form>
       </div>
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="pagedList"
+        border
+        stripe
+        size="small"
+        height="100%"
+        row-key="id"
+        @selection-change="onSelectionChange"
+      >
+        <el-table-column type="selection" width="42" align="center" reserve-selection />
         <el-table-column prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="90" />
         <el-table-column prop="name" label="姓名" width="88" />
@@ -56,6 +82,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -76,7 +103,7 @@
 
     <el-dialog v-model="editDlg" title="修改人员信息" width="920px" destroy-on-close>
       <p class="dialog-tip">
-        展示人员表全部字段;灰色为系统只读。保存后业务状态与居保匹配结果保持不变(退养资格除外)。
+        展示人员表全部字段;灰色为系统只读。保存后业务状态、居保匹配、退养资格均保持不变(资格只能由镇经发审批认定)。
       </p>
       <el-form v-loading="editLoading" :model="editForm" label-width="140px" size="small">
         <PersonnelFormFields
@@ -94,12 +121,21 @@
       </template>
     </el-dialog>
 
-    <el-dialog v-model="statusDlg" title="变更人员状态" width="420px" destroy-on-close>
+    <el-dialog v-model="statusDlg" title="变更人员状态" width="480px" destroy-on-close>
+      <p class="dialog-tip">
+        「待提交资格 / 待资格审批 / 新增」对应村录入与镇经发资格审批。「待人工确认」不是独立状态,对应「异常待处理」。
+        居保失败须由镇社区事务在「居保匹配」页人工确认;改人员信息不会自动改居保结果。
+      </p>
       <el-form label-width="96px" size="small">
         <el-form-item label="人员">{{ current?.name }}</el-form-item>
+        <el-form-item label="当前状态">
+          <StatusTag :status="current?.status || '—'" />
+        </el-form-item>
         <el-form-item label="业务状态" required>
           <el-select v-model="statusForm.bizStatus" style="width: 100%">
-            <el-option label="草稿" value="DRAFT" />
+            <el-option label="待提交资格" value="DRAFT" />
+            <el-option label="待资格审批" value="PENDING_TOWN_APPROVE" />
+            <el-option label="新增" value="NEW" />
             <el-option label="待居保匹配" value="PENDING_INSURANCE_MATCH" />
             <el-option label="正常发放" value="NORMAL" />
             <el-option label="已退出" value="EXITED" />
@@ -121,12 +157,64 @@
         <el-button type="primary" :loading="saving" @click="saveStatus">保存</el-button>
       </template>
     </el-dialog>
+
+    <el-dialog v-model="initDlg" title="初始化人员状态" width="520px" destroy-on-close>
+      <p class="dialog-tip">
+        将按所选范围一次性改写业务状态、居保匹配与退养资格,类似联调初始化。已归档人员跳过;不改姓名、身份证、银行卡,也不重算已生成批次。资格选「清空待认定」时会同时清空下次发放起始月。
+      </p>
+      <el-form label-width="108px" size="small">
+        <el-form-item label="范围" required>
+          <el-radio-group v-model="initForm.scope">
+            <el-radio value="all">全部人员</el-radio>
+            <el-radio value="filter">当前筛选街镇/村居</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="initForm.scope === 'filter'" label="当前筛选">
+          <span>{{ filterTown || '全区' }}{{ filterVillage ? ` / ${filterVillage}` : '' }}</span>
+        </el-form-item>
+        <el-form-item label="业务状态" required>
+          <el-select v-model="initForm.bizStatus" style="width: 100%">
+            <el-option label="待提交资格" value="DRAFT" />
+            <el-option label="待资格审批" value="PENDING_TOWN_APPROVE" />
+            <el-option label="新增" value="NEW" />
+            <el-option label="待居保匹配" value="PENDING_INSURANCE_MATCH" />
+            <el-option label="正常发放" value="NORMAL" />
+            <el-option label="异常待处理" value="ABNORMAL" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="居保匹配" required>
+          <el-select v-model="initForm.insuranceMatchResult" style="width: 100%">
+            <el-option label="匹配通过" value="SUCCESS" />
+            <el-option label="人工通过" value="MANUAL_PASS" />
+            <el-option label="未匹配" value="PENDING" />
+            <el-option label="匹配失败" value="FAIL" />
+            <el-option label="丧失资格" value="LOSE_ELIGIBILITY" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="退养资格" required>
+          <el-select v-model="initForm.retireQualifyType" style="width: 100%">
+            <el-option label="清空待认定" value="UNSET" />
+            <el-option label="有土地退养" value="WITH_LAND" />
+            <el-option label="无土地有资格" value="WITHOUT_LAND" />
+            <el-option label="无资格" value="INELIGIBLE" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="本月发放">
+          <el-switch v-model="initForm.payThisMonth" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="initDlg = false">取消</el-button>
+        <el-button type="warning" :loading="initing" @click="saveInit">确认初始化</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
 import { onMounted, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import type { TableInstance } from 'element-plus'
 import PagePanel from '@/components/common/PagePanel.vue'
 import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
@@ -134,19 +222,22 @@ import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescrip
 import PersonnelFormFields from '@/components/biz/PersonnelFormFields.vue'
 import {
   deletePersonnelAdmin,
+  deletePersonnelBatchAdmin,
   fetchPeople,
   fetchPersonDetail,
+  initPersonnelStatusAdmin,
   savePerson,
   updatePersonnelStatusAdmin,
 } from '@/api/biz'
 import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
-import { ensureVillages, listVillageNames } from '@/api/sjnmtybt/regionStore'
+import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
+import { ensureVillages, listVillageNames, resolveScopeIds } from '@/api/sjnmtybt/regionStore'
 import { getAppConfig } from '@/utils/appConfig'
 import { getVillagesByTown } from '@/api/mock/data'
 import { maskIdCard } from '@/utils/mask'
-import { isValidIdCard, isValidPhoneOptional } from '@/utils/validators'
+import { explainIdCard, isValidPhoneOptional } from '@/utils/validators'
 import { personnelStatusLabel } from '@/utils/statusLabels'
 import type { Person } from '@/types/biz'
 
@@ -163,6 +254,11 @@ const keyword = ref('')
 const list = ref<Person[]>([])
 const loading = ref(false)
 const saving = ref(false)
+const batchDeleting = ref(false)
+const initing = ref(false)
+const initDlg = ref(false)
+const tableRef = ref<TableInstance>()
+const selected = ref<Person[]>([])
 const statusDlg = ref(false)
 const editDlg = ref(false)
 const editLoading = ref(false)
@@ -175,6 +271,13 @@ const statusForm = ref({
   payThisMonth: true,
   remarks: '',
 })
+const initForm = ref({
+  scope: 'all' as 'all' | 'filter',
+  bizStatus: 'NORMAL',
+  insuranceMatchResult: 'SUCCESS',
+  retireQualifyType: 'UNSET',
+  payThisMonth: true,
+})
 
 const editForm = ref(emptyPersonForm())
 const editVillageOptions = ref<string[]>([])
@@ -227,6 +330,8 @@ async function search() {
     }
     list.value = rows
     resetPage()
+    tableRef.value?.clearSelection()
+    selected.value = []
   } finally {
     loading.value = false
   }
@@ -262,8 +367,9 @@ async function saveEdit() {
     ElMessage.warning('姓名、身份证号、银行卡号为必填')
     return
   }
-  if (!isValidIdCard(f.idCard)) {
-    ElMessage.warning('身份证号格式不正确(请勿输入中文或其它非法字符)')
+  const idReason = explainIdCard(f.idCard)
+  if (idReason) {
+    ElMessage.warning(idReason)
     return
   }
   if (!/^\d{12,22}$/.test(String(f.bankCard).trim())) {
@@ -274,6 +380,7 @@ async function saveEdit() {
     ElMessage.warning('手机号格式不正确')
     return
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     await savePerson({
@@ -296,9 +403,10 @@ async function saveEdit() {
 
 function openStatus(row: Person) {
   current.value = row
+  const code = String(row.bizStatus || '').trim() || 'NORMAL'
   statusForm.value = {
-    bizStatus: 'NORMAL',
-    payThisMonth: true,
+    bizStatus: code,
+    payThisMonth: row.payThisMonth !== false,
     remarks: '',
   }
   statusDlg.value = true
@@ -316,6 +424,7 @@ async function saveStatus() {
   } catch {
     return
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     await updatePersonnelStatusAdmin({
@@ -332,6 +441,89 @@ async function saveStatus() {
   }
 }
 
+function openInit() {
+  initForm.value = {
+    scope: filterTown.value ? 'filter' : 'all',
+    bizStatus: 'NORMAL',
+    insuranceMatchResult: 'SUCCESS',
+    retireQualifyType: 'UNSET',
+    payThisMonth: true,
+  }
+  initDlg.value = true
+}
+
+async function saveInit() {
+  if (initForm.value.scope === 'filter' && !filterTown.value) {
+    ElMessage.warning('当前未选街镇,请先筛选或改用「全部人员」')
+    return
+  }
+  const scopeText =
+    initForm.value.scope === 'filter'
+      ? `${filterTown.value || '全区'}${filterVillage.value ? ` / ${filterVillage.value}` : ''}`
+      : '全部人员'
+  const bizZh = personnelStatusLabel(initForm.value.bizStatus)
+  const insZh =
+    initForm.value.insuranceMatchResult === 'SUCCESS' || initForm.value.insuranceMatchResult === 'MANUAL_PASS'
+      ? '匹配通过'
+      : initForm.value.insuranceMatchResult === 'PENDING'
+        ? '未匹配'
+        : initForm.value.insuranceMatchResult === 'FAIL'
+          ? '匹配失败'
+          : '丧失资格'
+  const qualifyZh =
+    initForm.value.retireQualifyType === 'WITH_LAND'
+      ? '有土地退养'
+      : initForm.value.retireQualifyType === 'WITHOUT_LAND'
+        ? '无土地有资格'
+        : initForm.value.retireQualifyType === 'INELIGIBLE'
+          ? '无资格'
+          : '清空待认定'
+  try {
+    await ElMessageBox.confirm(
+      `确认将「${scopeText}」初始化为业务状态「${bizZh}」、居保「${insZh}」、退养资格「${qualifyZh}」?已归档人员跳过,已生成批次不会自动重算。`,
+      '初始化人员状态',
+      { type: 'warning', confirmButtonText: '继续' },
+    )
+    const { value } = await ElMessageBox.prompt(
+      '请输入 INIT_STATUS_CONFIRM 以二次确认',
+      '二次确认',
+      {
+        confirmButtonText: '执行初始化',
+        cancelButtonText: '取消',
+        inputPattern: /^INIT_STATUS_CONFIRM$/,
+        inputErrorMessage: '请完整输入 INIT_STATUS_CONFIRM',
+      },
+    )
+    if (value !== 'INIT_STATUS_CONFIRM') return
+  } catch {
+    return
+  }
+  if (guardBusy(initing)) return
+  initing.value = true
+  try {
+    let townId = ''
+    let villageId = ''
+    if (initForm.value.scope === 'filter') {
+      const scope = await resolveScopeIds(filterTown.value, filterVillage.value || undefined)
+      townId = scope.townId || ''
+      villageId = scope.villageId || ''
+    }
+    const res = await initPersonnelStatusAdmin({
+      bizStatus: initForm.value.bizStatus,
+      insuranceMatchResult: initForm.value.insuranceMatchResult,
+      retireQualifyType: initForm.value.retireQualifyType,
+      payThisMonth: initForm.value.payThisMonth,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+    })
+    ElMessage.success(res.message || `已初始化 ${res.updatedCount} 人`)
+    initDlg.value = false
+    await search()
+  } finally {
+    initing.value = false
+  }
+}
+
 async function onDelete(row: Person) {
   try {
     await ElMessageBox.confirm(
@@ -342,21 +534,59 @@ async function onDelete(row: Person) {
   } catch {
     return
   }
-  await deletePersonnelAdmin(String(row.id))
-  ElMessage.success('人员已删除')
-  await search()
+  await runSubmit(saving, async () => {
+    await deletePersonnelAdmin(String(row.id))
+    ElMessage.success('人员已删除')
+    await search()
+  })
+}
+
+function onSelectionChange(rows: Person[]) {
+  selected.value = rows || []
+}
+
+async function onBatchDelete() {
+  const rows = selected.value
+  if (!rows.length) {
+    ElMessage.warning('请先勾选要删除的人员')
+    return
+  }
+  const names = rows.slice(0, 8).map((p) => p.name || p.id).join('、')
+  const extra = rows.length > 8 ? ` 等 ${rows.length} 人` : ''
+  try {
+    await ElMessageBox.confirm(
+      `确认删除「${names}」${extra}?将归档并从未闭环批次明细中摘离。`,
+      '批量删除人员',
+      { type: 'warning', confirmButtonText: `确认删除 ${rows.length} 人` },
+    )
+  } catch {
+    return
+  }
+  if (guardBusy(batchDeleting)) return
+  batchDeleting.value = true
+  try {
+    const res = (await deletePersonnelBatchAdmin(rows.map((p) => String(p.id)))) as {
+      successCount?: number
+      failCount?: number
+      message?: string
+      errors?: string[]
+    }
+    const fail = Number(res?.failCount ?? 0)
+    if (fail > 0) {
+      ElMessage.warning(res?.message || `删除完成,失败 ${fail} 人`)
+    } else {
+      ElMessage.success(res?.message || `已删除 ${rows.length} 人`)
+    }
+    await search()
+  } finally {
+    batchDeleting.value = false
+  }
 }
 
 onMounted(search)
 </script>
 
 <style scoped lang="less">
-.tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
 .dialog-tip {
   margin: 0 0 12px;
   font-size: 12px;

+ 17 - 15
src/views/admin/RegionsAdminView.vue

@@ -1,8 +1,11 @@
 <template>
-  <div class="regions-admin-view">
+  <div class="page-fill regions-admin-view">
     <PagePanel title="行政区划管理">
-      <p class="tip">
-        维护区/街镇/村居名称与资金分担比例。业务页面只展示中文名称;本页「区划编码」仅供运维对接,请勿随意改动。街镇区+镇承担比例之和须为 100。
+      <template #extra>
+        <el-button type="primary" size="small" @click="openCreate">新增区划</el-button>
+      </template>
+      <p class="page-tip">
+        维护区/街镇/村居名称与资金分担比例。业务页只展示中文名;「区划编码」仅供运维对接。街镇的区+镇承担比例之和须为 100。
       </p>
       <div class="query-panel">
         <el-form :inline="true" size="small">
@@ -20,11 +23,11 @@
           </el-form-item>
           <el-form-item>
             <el-button type="primary" @click="search">查询</el-button>
-            <el-button @click="openCreate">新增区划</el-button>
           </el-form-item>
         </el-form>
       </div>
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
         <el-table-column prop="name" label="名称" min-width="140" />
         <el-table-column label="层级" width="88">
           <template #default="{ row }">{{ levelLabel(row.level) }}</template>
@@ -52,6 +55,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -103,7 +107,7 @@
               placeholder="如叶榭10"
             />
           </el-form-item>
-          <p class="tip" style="margin: -6px 0 10px">
+          <p class="page-tip" style="margin: -6px 0 10px">
             缺省:叶榭 90/10,佘山/车墩 60/40,其余 50/50。区+镇宜为 100。
           </p>
         </template>
@@ -133,6 +137,7 @@ import {
   saveRegionAdmin,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import type { RegionRow } from '@/api/sjnmtybt/regionStore'
 
 const q = reactive({ level: 'TOWN', parentId: '' })
@@ -250,6 +255,7 @@ async function save() {
       return
     }
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     await saveRegionAdmin({
@@ -283,9 +289,11 @@ async function onDelete(row: RegionRow) {
   } catch {
     return
   }
-  await deleteRegionAdmin(row.id)
-  ElMessage.success('已删除')
-  await search()
+  await runSubmit(saving, async () => {
+    await deleteRegionAdmin(row.id)
+    ElMessage.success('已删除')
+    await search()
+  })
 }
 
 onMounted(async () => {
@@ -295,12 +303,6 @@ onMounted(async () => {
 </script>
 
 <style scoped lang="less">
-.tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
 .query-panel {
   margin-bottom: 10px;
 }

+ 21 - 16
src/views/alerts/AlertsView.vue

@@ -1,7 +1,7 @@
 <template>
-  <div class="alerts-view">
+  <div class="page-fill alerts-view">
     <PagePanel title="监控告警">
-      <p class="alerts-view__tip">{{ tipText }}</p>
+      <p class="page-tip">{{ tipText }}</p>
       <div v-if="isAdmin" class="query-panel alerts-view__filter">
         <el-form :inline="true" size="small">
           <el-form-item label="街镇">
@@ -26,7 +26,8 @@
           </el-form-item>
         </el-form>
       </div>
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
         <el-table-column prop="batchNo" label="批次号" min-width="120" align="center" show-overflow-tooltip />
         <el-table-column prop="personName" label="人员" width="90" />
         <el-table-column prop="town" label="街镇" width="90" />
@@ -68,6 +69,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -218,6 +220,7 @@ import {
   uploadBizFile,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy } from '@/composables/useSubmitLock'
 import {
   useDynamicRegionOptions,
   useTownVillageOptions,
@@ -229,6 +232,7 @@ import {
   DISK_RETURN_FAIL_TIP,
 } from '@/constants/diskReturn'
 import { emptyPersonForm, personToForm } from '@/constants/personnelFields'
+import { explainIdCard } from '@/utils/validators'
 import type { AlertItem, AlertLifecycleItem, Person } from '@/types/biz'
 
 const userStore = useUserStore()
@@ -316,8 +320,9 @@ const formRules: FormRules = {
           cb(new Error('请输入身份证号'))
           return
         }
-        if (!/^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(s)) {
-          cb(new Error('身份证号格式不正确(请勿填写中文或其它字符)'))
+        const reason = explainIdCard(s)
+        if (reason) {
+          cb(new Error(reason))
           return
         }
         cb()
@@ -363,15 +368,21 @@ const formRules: FormRules = {
 const tipText = computed(() => {
   const role = userStore.roleInfo
   if (role?.key === 'admin') {
-    return '系统告警对应流程图红色提示框:含民政死亡比对、居保匹配失败、回盘发放失败等。系统管理员可查看并处理全区告警,请按街镇+村居筛选后操作。'
+    return '含民政死亡比对、居保匹配失败、回盘发放失败等。可按街镇+村居处理后关闭;日常由村或镇社区事务办理。'
+  }
+  if (role?.key === 'hr') {
+    return '全区告警只读(不可关闭)。含居保失败、回盘失败、死亡比对等,由镇社区事务或村合作社处理。'
   }
   if (role?.village) {
-    return `系统告警对应流程图红色提示框:含民政死亡比对、居保匹配失败、回盘发放失败等。当前为村级视角,仅展示本村(${role.town || ''}${role.village})告警;与镇社区事务受理中心共用同一告警池,任一方处理后状态即时同步。`
+    return `仅本村(${role.town || ''}${role.village})告警,与镇社区事务共用;任一方处理后即时同步。常见:退养资格驳回、居保失败、回盘失败、死亡比对。`
+  }
+  if (role?.key === 'social') {
+    return `本镇(${role.town || ''})各村告警,与对应村共用。居保失败须先在「居保状态匹配」人工确认,再关闭告警。`
   }
   if (role && !role.allData && role.town) {
-    return `系统告警对应流程图红色提示框:含民政死亡比对、居保匹配失败、回盘发放失败等。当前为本镇(${role.town})视角,含各村告警;与对应村合作社共用同一告警数据,任一方处理后状态即时同步。区人社局可查看、不可关闭。`
+    return `本镇(${role.town})告警。区人社局可查看、不可关闭。`
   }
-  return '系统告警对应流程图红色提示框:含民政死亡比对、居保匹配失败、回盘发放失败等。区人社局可查看、不可关闭。'
+  return '含民政死亡比对、居保匹配失败、回盘发放失败等。区人社局可查看、不可关闭。'
 })
 
 function resetForm() {
@@ -485,6 +496,7 @@ async function onSubmitHandle() {
     return
   }
 
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     const f = form.value
@@ -570,13 +582,6 @@ watch(
 </script>
 
 <style scoped lang="less">
-.alerts-view__tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
-
 .alerts-view__filter {
   margin-bottom: 10px;
   padding: 8px 12px 0;

+ 369 - 141
src/views/approvals/ApprovalsView.vue

@@ -1,7 +1,7 @@
 <template>
-  <div class="approvals-view">
+  <div class="page-fill approvals-view">
     <PagePanel :title="pageTitle">
-      <template #extra>
+      <template v-if="!isHistoryTab" #extra>
         <el-button
           type="primary"
           :disabled="!selectedIds.length"
@@ -14,26 +14,35 @@
         </el-button>
       </template>
 
-      <p class="approvals-view__tip">
-        <template v-if="isTownReview">
-          待审批为本镇当前月(批次 {{ currentBatchNo }})各村已提交名单。请先在「详情」中完成资格核验,再整批通过或驳回;人数以本批发放明细为准。
+      <p class="page-tip">
+        <template v-if="isHistoryTab && isTownReview">
+          已办结的资格审批。通过为「新增」(等 1 号纳入月发),无资格为异常待处理,补材料退回「待提交资格」。
+        </template>
+        <template v-else-if="isHistoryTab && isLeaderReview">
+          已办结的本镇月度清单与特殊业务审批。清单通过后社区事务可出盘;驳回后退回社区事务继续办理。
+        </template>
+        <template v-else-if="isHistoryTab">
+          已办结审批,可按条件筛选查看,不可再次审批。
+        </template>
+        <template v-else-if="isTownReview">
+          按人审批本镇退养资格(不看见批次)。待审只含村合作社已点「提交资格」的人员;未提交的请到「人员新增录入」办理。通过后为「新增」等 1 号纳入;已在正常发放的只补登记资格、不改发放状态。
         </template>
         <template v-else-if="isLeaderReview">
-          审批社区事务中心提交的月补贴清单与特殊业务。月补贴通过后可出盘;驳回退回居保匹配。特殊业务驳回退回业务补充。
+          审本镇月度清单(镇统计)与特殊业务。通过后社区事务可出盘。提交到本节点前,本镇当月特殊业务须全部通过
         </template>
         <template v-else>
-          镇经发初审通过后进入居保匹配;分管领导审月补贴/特殊业务。驳回分别退回村合作社或居保节点。
+          管理员可按街镇协查:镇经发审退养资格;分管领导审本镇月度清单与特殊业务。日常审批由对应镇办理
         </template>
       </p>
 
-      <el-tabs v-model="activeTab" @tab-change="loadApprovals">
-        <el-tab-pane :label="isTownReview ? '待审批(本月提交名单)' : '待审批'" name="pending" />
-        <el-tab-pane label="全部" name="all" />
+      <el-tabs v-model="activeTab" @tab-change="onTabChange">
+        <el-tab-pane :label="isTownReview ? '待资格审批' : '待审批'" name="pending" />
+        <el-tab-pane :label="isTownReview ? '已审记录' : '历史记录'" name="history" />
       </el-tabs>
 
-      <div v-if="isAdmin" class="query-panel approvals-view__filter">
+      <div class="query-panel approvals-view__filter">
         <el-form :inline="true" size="small">
-          <el-form-item label="街镇">
+          <el-form-item v-if="isAdmin" label="街镇">
             <el-select
               v-model="filterTown"
               placeholder="全部"
@@ -49,6 +58,21 @@
               <el-option v-for="v in villageOptions" :key="v" :label="v" :value="v" />
             </el-select>
           </el-form-item>
+          <el-form-item v-if="!isTownReview" label="批次号">
+            <el-input
+              v-model="filterBatchNo"
+              placeholder="如 202608"
+              clearable
+              style="width: 130px"
+              @keyup.enter="applyFilter"
+            />
+          </el-form-item>
+          <el-form-item v-if="isHistoryTab" label="状态">
+            <el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
+              <el-option label="已通过" value="已通过" />
+              <el-option label="已驳回" value="已驳回" />
+            </el-select>
+          </el-form-item>
           <el-form-item>
             <el-button type="primary" @click="applyFilter">查询</el-button>
             <el-button @click="resetFilter">重置</el-button>
@@ -56,18 +80,20 @@
         </el-form>
       </div>
 
+      <div class="page-fill__table">
       <el-table
         v-loading="loading"
         :data="pagedList"
         border
         stripe
         size="small"
+        height="100%"
         @selection-change="onSelectionChange"
       >
-        <el-table-column type="selection" width="42" align="center" />
+        <el-table-column v-if="!isHistoryTab" type="selection" width="42" align="center" />
         <el-table-column prop="bizType" label="业务类型" width="130" />
         <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
-        <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
+        <el-table-column v-if="!isTownReview" prop="batchNo" label="批次号" width="88" align="center" />
         <el-table-column v-if="!isTownReview || isAdmin" prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="90" />
         <el-table-column label="人数" width="64" align="center">
@@ -81,7 +107,7 @@
             }}
           </template>
         </el-table-column>
-        <el-table-column label="总发放金额" width="120" align="right">
+        <el-table-column v-if="!isTownReview" label="总发放金额" width="120" align="right">
           <template #default="{ row }">
             {{
               (row as Approval).totalAmount != null
@@ -137,6 +163,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -150,7 +177,7 @@
     <el-dialog
       v-model="detailVisible"
       title="审批详情"
-      :width="isTownReview ? '1100px' : '1040px'"
+      :width="isTownReview ? '1180px' : '1120px'"
       destroy-on-close
       @closed="onDetailClosed"
     >
@@ -158,7 +185,8 @@
         <el-descriptions-item label="业务类型">{{ currentRow.bizType }}</el-descriptions-item>
         <el-descriptions-item label="审批节点">{{ currentRow.node }}</el-descriptions-item>
         <el-descriptions-item label="标题" :span="2">{{ currentRow.title }}</el-descriptions-item>
-        <el-descriptions-item label="批次号">{{ currentRow.batchNo }}</el-descriptions-item>
+        <el-descriptions-item v-if="!isQualifyApproval(currentRow)" label="批次号">{{ currentRow.batchNo }}</el-descriptions-item>
+        <el-descriptions-item v-else label="人员">{{ currentRow.personName || currentRow.title }}</el-descriptions-item>
         <el-descriptions-item label="状态">
           <StatusTag :status="currentRow.status" />
         </el-descriptions-item>
@@ -166,7 +194,7 @@
         <el-descriptions-item label="村居">{{ currentRow.village || '-' }}</el-descriptions-item>
         <el-descriptions-item label="申请人">{{ currentRow.applicant }}</el-descriptions-item>
         <el-descriptions-item label="提交时间">{{ currentRow.createdAt }}</el-descriptions-item>
-        <el-descriptions-item v-if="!isSpecialApproval(currentRow)" label="总发放金额" :span="2">
+        <el-descriptions-item v-if="!isSpecialApproval(currentRow) && !isQualifyApproval(currentRow)" label="总发放金额" :span="2">
           <strong>{{ formatAmount(detailAmountSummary.payable) }}</strong>
           <span class="muted" style="margin-left: 8px">
             <template v-if="isAdjustDetailBatch">(调标差额批:应发=补发 {{ formatAmount(detailAmountSummary.supplement) }})</template>
@@ -184,7 +212,7 @@
         <el-descriptions-item label="审批意见" :span="2">
           {{ currentRow.opinion || '-' }}
         </el-descriptions-item>
-        <template v-if="isSpecialApproval(currentRow)">
+        <template v-if="isSpecialApproval(currentRow) || isQualifyApproval(currentRow)">
           <el-descriptions-item v-if="currentRow.personName" label="人员">
             {{ currentRow.personName }}
           </el-descriptions-item>
@@ -200,15 +228,18 @@
         </template>
       </el-descriptions>
 
-      <p v-if="isSpecialApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
+      <p v-if="isQualifyApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
+        退养资格:通过后人员状态为「新增」,等待每月 1 号纳入全区月发;认定无资格进入异常待处理;驳回补材料退回村合作社。
+      </p>
+      <p v-else-if="isSpecialApproval(currentRow)" class="approvals-view__reject-tip" style="margin-top: 12px">
         特殊业务:通过后系统按业务类型回写人员状态(死亡登记通过后改为「死亡丧葬」并停止续发);驳回后退回业务受理补充材料。
       </p>
 
       <!-- 特殊业务:涉及人员(通常 1 人) -->
-      <div v-if="isSpecialApproval(currentRow)" class="approvals-view__people">
+      <div v-if="isSpecialApproval(currentRow) || isQualifyApproval(currentRow)" class="approvals-view__people">
         <div class="approvals-view__people-toolbar">
           <div class="approvals-view__people-title">
-            涉及人员
+            {{ isQualifyApproval(currentRow) ? '申请人员' : '涉及人员' }}
             <em>共 {{ detailPeople.length || (currentRow?.peopleCount ?? 0) }} 人</em>
           </div>
         </div>
@@ -234,6 +265,18 @@
               <StatusTag :status="row.status" />
             </template>
           </el-table-column>
+          <el-table-column label="操作" width="72" align="center" fixed="right">
+            <template #default="{ row }">
+              <el-button
+                link
+                type="primary"
+                size="small"
+                @click="openPersonDetail(row as Person)"
+              >
+                详情
+              </el-button>
+            </template>
+          </el-table-column>
         </el-table>
         <el-empty
           v-if="!peopleLoading && !detailPeople.length"
@@ -245,7 +288,7 @@
       <div v-else class="approvals-view__people">
         <div class="approvals-view__people-toolbar">
           <div class="approvals-view__people-title">
-            {{ isTownReview ? '村合作社提交名单' : '本人员列表' }}
+            {{ isTownReview ? '村合作社提交名单' : '本人员列表' }}
             <span v-if="currentRow?.village">
               ({{ currentRow.town }}{{ currentRow.village }} · 批次 {{ currentRow.batchNo }})
             </span>
@@ -255,7 +298,7 @@
             </em>
             <em v-if="detailStatusFilter">,当前 {{ filteredDetailPeople.length }} 人</em>
             <em v-if="isTownReview && pendingQualifyCount > 0" class="approvals-view__people-warn">
-              待核验 {{ pendingQualifyCount }} 人
+              待核验 {{ pendingQualifyCount }} 人,须核验后才能通过
             </em>
           </div>
           <el-select
@@ -358,31 +401,44 @@
             </template>
           </el-table-column>
           <el-table-column
-            v-if="isTownReview"
             label="操作"
-            width="110"
+            :width="isTownReview ? 168 : 72"
             align="center"
             fixed="right"
           >
             <template #default="{ row }">
               <el-button
-                v-if="canReviewQualify(row as Person)"
                 link
                 type="primary"
                 size="small"
-                :loading="reviewingId === row.id"
-                @click="openQualifyDialog(row as Person)"
+                @click="openPersonDetail(row as Person)"
               >
-                核验资格
+                详情
               </el-button>
-              <span
-                v-else-if="row.retireQualifyType && row.retireQualifyType !== '无资格'"
-                class="muted"
-              >
-                已核验
-              </span>
-              <span v-else-if="isPastQualified(row as Person)" class="muted">往期已核验</span>
-              <span v-else class="muted">—</span>
+              <template v-if="isTownReview">
+                <el-button
+                  v-if="canReviewQualify(row as Person)"
+                  link
+                  type="primary"
+                  size="small"
+                  :loading="reviewingId === row.id"
+                  @click="openQualifyDialog(row as Person)"
+                >
+                  核验资格
+                </el-button>
+                <span
+                  v-else-if="row.retireQualifyType === '无资格' || row.status === '无资格'"
+                  class="muted"
+                >
+                  无资格
+                </span>
+                <span
+                  v-else-if="row.retireQualifyType"
+                  class="muted"
+                >
+                  已核验
+                </span>
+              </template>
             </template>
           </el-table-column>
         </el-table>
@@ -393,7 +449,7 @@
         />
         <el-empty
           v-if="!peopleLoading && !detailPeople.length"
-          description="该村本批次暂无人员数据"
+          description="暂无待审批人员"
           :image-size="64"
         />
         <el-empty
@@ -411,8 +467,8 @@
         <el-button
           type="primary"
           :loading="deciding"
-          :disabled="hasIneligibleMembers"
-          :title="hasIneligibleMembers ? '存在「无资格」人员,只能驳回由村处理' : undefined"
+          :disabled="townPassBlocked"
+          :title="townPassBlockedReason || undefined"
           @click="onDecide(currentRow!, true)"
         >
           通过
@@ -429,12 +485,20 @@
       title="确认通过"
       width="520px"
       destroy-on-close
+      :close-on-click-modal="!deciding"
+      :close-on-press-escape="!deciding"
       @closed="onPassDialogClosed"
     >
       <p class="approvals-view__reject-tip" style="color: var(--el-text-color-secondary)">
         {{ passTip }}
       </p>
       <el-form label-width="90px" size="small">
+        <el-form-item v-if="isQualifyPassTarget" label="资格认定" required>
+          <el-radio-group v-model="passQualifyType">
+            <el-radio value="有土地退养">有土地退养</el-radio>
+            <el-radio value="无土地有资格">无土地有资格</el-radio>
+          </el-radio-group>
+        </el-form-item>
         <el-form-item label="审批意见">
           <el-input
             v-model="passOpinion"
@@ -464,8 +528,8 @@
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="passVisible = false">取消</el-button>
-        <el-button type="primary" :loading="deciding" @click="confirmPass">确认通过</el-button>
+        <el-button :disabled="deciding" @click="passVisible = false">取消</el-button>
+        <el-button type="primary" :loading="deciding" :disabled="deciding" @click="confirmPass">确认通过</el-button>
       </template>
     </el-dialog>
 
@@ -475,25 +539,36 @@
       title="填写驳回理由"
       width="520px"
       destroy-on-close
+      :close-on-click-modal="!deciding"
+      :close-on-press-escape="!deciding"
       @closed="onRejectDialogClosed"
     >
       <p class="approvals-view__reject-tip">
-        <template v-if="isTownReview && ineligibleMembers.length">
+        <template v-if="isQualifyRejectTarget">
+          「无资格」将人员标为异常待处理;「补材料」退回村合作社「待提交资格」后可重提。
+        </template>
+        <template v-else-if="isTownReview && ineligibleMembers.length">
           本批有 {{ ineligibleMembers.length }} 人已认定「无资格」({{
             ineligibleMembers.map((p) => p.name).join('、')
           }})。驳回后将退回村合作社:请取消该人员或流程备注说明后重新提交。
         </template>
         <template v-else-if="isTownReview">
-          驳回后批次将退回村合作社修改,请明确说明退回原因
+          驳回后资格申请退回村合作社补材料或认定无资格
         </template>
         <template v-else-if="isLeaderReview">
-          月补贴清单驳回后退回居保匹配;特殊业务驳回后退回业务受理补充/修改。请明确说明退回原因。
+          月度清单驳回后退回社区事务继续办理;特殊业务驳回后退回业务受理补充/修改。请写明原因。
         </template>
         <template v-else>
-          驳回后批次将退回修改,请明确说明退回原因。
+          驳回后将退回修改,请明确说明退回原因。
         </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"
@@ -523,15 +598,15 @@
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="rejectVisible = false">取消</el-button>
-        <el-button type="danger" :loading="deciding" @click="confirmReject">确认驳回</el-button>
+        <el-button :disabled="deciding" @click="rejectVisible = false">取消</el-button>
+        <el-button type="danger" :loading="deciding" :disabled="deciding" @click="confirmReject">确认驳回</el-button>
       </template>
     </el-dialog>
 
     <!-- 镇经发:核验退养资格 -->
     <el-dialog v-model="qualifyVisible" title="核验退养资格" width="520px" destroy-on-close>
       <p class="approvals-view__qualify-tip">
-        请确认该新增人员是否具备土地退养资格。有资格将写入「退养资格」字段;认定「无资格」后本批只能驳回,由村取消该人员或流程备注说明。人员状态仍显示业务底数状态(如待镇初审/正常在库)
+        请确认该人员是否具备土地退养资格。选择「有土地退养」或「无土地有资格」后通过,人员变为「新增」,等每月 1 号纳入全区月发。认定「无资格」则进入异常待处理
       </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>
@@ -550,15 +625,31 @@
         </el-form-item>
       </el-form>
       <p v-if="qualifyType === '无资格'" class="approvals-view__qualify-warn">
-        认定无资格后,本批次不可「通过」,只能「驳回」退回村合作社处理(取消该人员或补充说明后重提)
+        认定无资格后,人员进入异常待处理,不再纳入每月 1 号全区月发
       </p>
       <template #footer>
-        <el-button @click="qualifyVisible = false">取消</el-button>
-        <el-button type="primary" :loading="reviewingId !== null" @click="confirmQualify">
+        <el-button :disabled="reviewingId !== null" @click="qualifyVisible = false">取消</el-button>
+        <el-button type="primary" :loading="reviewingId !== null" :disabled="reviewingId !== null" @click="confirmQualify">
           确认核验
         </el-button>
       </template>
     </el-dialog>
+
+    <el-dialog
+      v-model="personDetailVisible"
+      :title="personDetail ? `人员详情 · ${personDetail.name}` : '人员详情'"
+      width="920px"
+      append-to-body
+      destroy-on-close
+      @closed="onPersonDetailClosed"
+    >
+      <div v-loading="personDetailLoading">
+        <PersonnelDetailDescriptions v-if="personDetail" :person="personDetail" />
+      </div>
+      <template #footer>
+        <el-button type="primary" @click="personDetailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -571,10 +662,12 @@ import StatusTag from '@/components/common/StatusTag.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import FlowAttachments from '@/components/biz/FlowAttachments.vue'
+import PersonnelDetailDescriptions from '@/components/biz/PersonnelDetailDescriptions.vue'
 import {
   decideApproval,
   fetchApprovals,
   fetchBatchMemberPeople,
+  fetchPersonDetail,
   fetchSpecialBizPerson,
   reviewPersonQualify,
   uploadBizFile,
@@ -582,6 +675,7 @@ import {
 import { parseApprovalId } from '@/api/sjnmtybt/mappers'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
+import { guardBusy } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
 import { isYearlyAdjustBatchLike } from '@/utils/batchAdjust'
@@ -597,10 +691,13 @@ const {
   villageOptions,
   onTownChange,
   resetTownVillage,
-  matchTownVillage,
+  loadVillages,
 } = useTownVillageOptions()
 
 const activeTab = ref('pending')
+const isHistoryTab = computed(() => activeTab.value === 'history')
+const filterBatchNo = ref('')
+const filterStatus = ref('')
 const list = ref<Approval[]>([])
 const loading = ref(false)
 const deciding = ref(false)
@@ -649,6 +746,8 @@ const passTarget = ref<Approval | null>(null)
 const passBatchMode = ref(false)
 const passFileList = ref<UploadUserFile[]>([])
 const passRawFiles = ref<File[]>([])
+const passQualifyType = ref<'有土地退养' | '无土地有资格'>('有土地退养')
+const rejectQualifyMode = ref<'supplement' | 'ineligible'>('supplement')
 const batchDrawerVisible = ref(false)
 const batchDrawerId = ref('')
 
@@ -664,6 +763,12 @@ function isSpecialApproval(row: Approval | null | undefined): boolean {
   return parsed?.kind === 'special'
 }
 
+function isQualifyApproval(row: Approval | null | undefined): boolean {
+  if (!row) return false
+  const parsed = parseApprovalId(String(row.id || ''))
+  return parsed?.kind === 'qualify' || row.bizType === '退养资格审批'
+}
+
 function syncRejectFiles(_file: UploadFile, fileList: UploadFile[]) {
   rejectFileList.value = fileList
   rejectRawFiles.value = fileList.map((f) => f.raw).filter((f): f is File => !!f)
@@ -691,6 +796,17 @@ function onPassDialogClosed() {
   passBatchMode.value = false
 }
 
+const isQualifyPassTarget = computed(
+  () =>
+    isQualifyApproval(passTarget.value) ||
+    (passBatchMode.value && isTownReview.value),
+)
+const isQualifyRejectTarget = computed(
+  () =>
+    isQualifyApproval(rejectTarget.value) ||
+    (rejectBatchMode.value && isTownReview.value),
+)
+
 async function uploadApproveFiles(files: File[]): Promise<string | undefined> {
   if (!files.length) return undefined
   const uploaded: string[] = []
@@ -715,6 +831,9 @@ const qualifyVisible = ref(false)
 const qualifyTarget = ref<Person | null>(null)
 const qualifyType = ref<'有土地退养' | '无土地有资格' | '无资格'>('有土地退养')
 const reviewingId = ref<number | string | null>(null)
+const personDetailVisible = ref(false)
+const personDetailLoading = ref(false)
+const personDetail = ref<Person | null>(null)
 
 function qualifyTagType(t?: string): 'success' | 'warning' | 'danger' | 'info' {
   if (t === '无资格') return 'danger'
@@ -723,8 +842,13 @@ function qualifyTagType(t?: string): 'success' | 'warning' | 'danger' | 'info' {
 }
 
 const filteredList = computed(() => {
-  if (!isAdmin.value) return list.value
-  return list.value.filter((row) => matchTownVillage(row))
+  let rows = list.value
+  if (filterTown.value) rows = rows.filter((row) => row.town === filterTown.value)
+  if (filterVillage.value) rows = rows.filter((row) => row.village === filterVillage.value)
+  const batchNo = filterBatchNo.value.trim()
+  if (batchNo) rows = rows.filter((row) => row.batchNo.includes(batchNo))
+  if (filterStatus.value) rows = rows.filter((row) => row.status === filterStatus.value)
+  return rows
 })
 
 const { page, pageSize, total, pagedList, resetPage } = useClientPagination(filteredList)
@@ -734,9 +858,23 @@ function applyFilter() {
 }
 
 function resetFilter() {
-  resetTownVillage()
+  filterBatchNo.value = ''
+  filterStatus.value = ''
+  if (isAdmin.value) {
+    resetTownVillage()
+  } else {
+    filterVillage.value = ''
+  }
   resetPage()
 }
+
+async function syncScopeTown() {
+  const town = userStore.roleInfo?.town
+  if (!isAdmin.value && town) {
+    filterTown.value = town
+    await loadVillages(town)
+  }
+}
 const detailStatusFilter = ref('')
 const detailStatusOptions = computed(() => {
   const set = new Set<string>()
@@ -768,7 +906,7 @@ const isTownReview = computed(() => userStore.currentRole === 'town')
 const isLeaderReview = computed(() => userStore.currentRole === 'leader')
 
 const pageTitle = computed(() => {
-  if (isTownReview.value) return '审批管理(土地退养资格初审)'
+  if (isTownReview.value) return '退养资格审批'
   if (isLeaderReview.value) return '审批管理(分管领导线上审批)'
   if (isAdmin.value) return '审批管理(全区 · 按镇+村)'
   return '审批管理'
@@ -777,11 +915,23 @@ 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} 人,只能驳回由村处理`
+  }
+  if (pendingQualifyCount.value > 0) {
+    return `有 ${pendingQualifyCount.value} 人退养资格尚未核验,请先点「核验资格」`
+  }
+  return ''
+})
+const townPassBlocked = computed(() => Boolean(townPassBlockedReason.value))
+
 /** 已认定「无资格」的人员:批次只能驳回 */
 const ineligibleMembers = computed(() =>
   detailPeople.value.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格'),
@@ -792,29 +942,19 @@ function onSelectionChange(rows: Approval[]) {
   selectedIds.value = rows.map((r) => r.id)
 }
 
-/** 本批次新增且尚未完成资格核验的状态 */
-const NEW_PENDING_STATUSES = ['草稿', '人员新增录入', '退回修改', '待人工确认', '待镇初审']
-
 function hasQualifyResult(row: Person) {
   return Boolean(row.retireQualifyType)
 }
 
-/** 是否本批次新增、仍待资格核验(以退养资格字段为准,不伪造人员状态文案) */
+/** 退养资格未填即待核验(不再用「正常发放」推断为往期已核验) */
 function needsQualifyReview(row: Person) {
+  if ((row as BatchMemberPerson).changeFlag === 'REMOVE') return false
   if (hasQualifyResult(row)) return false
   if (row.status === '无资格') return false
-  return NEW_PENDING_STATUSES.includes(row.status) || row.status.includes('待审')
+  return true
 }
 
-/** 往期已通过资格审核(历史在册等),无需再次核验 */
-function isPastQualified(row: Person) {
-  if (row.status === '无资格' || row.retireQualifyType === '无资格') return false
-  if (hasQualifyResult(row)) return false
-  if (needsQualifyReview(row)) return false
-  return row.status === '正常发放' || row.status === '正常在库' || /通过|在库|发放|暂停|停止/.test(row.status)
-}
-
-/** 待审批名单中的新增人员可做资格核验;往期已通过的不可再审 */
+/** 待审批名单中资格未填的人员可核验 */
 function canReviewQualify(row: Person) {
   if (!isTownReview.value) return false
   if (currentRow.value?.status !== '待审批') return false
@@ -829,8 +969,30 @@ function openQualifyDialog(row: Person) {
   qualifyVisible.value = true
 }
 
+async function openPersonDetail(row: Person) {
+  const id = String(row.id || currentRow.value?.personnelId || '')
+  if (!id || id === 'undefined') {
+    ElMessage.warning('未关联到在册人员,无法查看详情')
+    return
+  }
+  personDetail.value = row
+  personDetailVisible.value = true
+  personDetailLoading.value = true
+  try {
+    const full = await fetchPersonDetail(id)
+    if (full) personDetail.value = full
+  } finally {
+    personDetailLoading.value = false
+  }
+}
+
+function onPersonDetailClosed() {
+  personDetail.value = null
+}
+
 async function confirmQualify() {
   if (!qualifyTarget.value) return
+  if (guardBusy(reviewingId)) return
   reviewingId.value = qualifyTarget.value.id
   try {
     await reviewPersonQualify({
@@ -876,9 +1038,10 @@ async function openDetail(row: Approval) {
   detailStatusFilter.value = ''
   peopleLoading.value = true
   try {
-    if (isSpecialApproval(row)) {
-      let person = row.personnelId
-        ? await fetchSpecialBizPerson(row.personnelId)
+    if (isQualifyApproval(row) || isSpecialApproval(row)) {
+      const pid = row.personnelId || (isQualifyApproval(row) ? parseApprovalId(String(row.id || ''))?.bizId : '')
+      let person = pid
+        ? (isQualifyApproval(row) ? await fetchPersonDetail(pid) : await fetchSpecialBizPerson(pid))
         : null
       if (!person && row.personName) {
         // 兜底:列表已有姓名时先展示一行,避免详情空白
@@ -968,67 +1131,96 @@ async function doDecide(
   opinion?: string,
   fileUrls?: string,
 ) {
-  deciding.value = true
-  try {
-    await decideApproval({ id: row.id, pass, opinion, fileUrls })
-    const isTown = row.node.includes('镇经发')
-    const isMonthly = row.bizType.includes('月') || row.bizType.includes('清单')
-    if (pass) {
-      const isDeath = row.bizType.includes('死亡')
-      ElMessage.success(
-        isTown
-          ? '已通过,批次状态已更新为「居保状态匹配」'
-          : isMonthly
-            ? '已通过,批次进入「已审核」,可由业务部门出盘'
-            : isDeath
-              ? '已通过,人员状态已更新为「死亡丧葬」'
-              : '已通过',
-      )
-    } else {
-      ElMessage.success(
-        isTown
+  const qualifyType = isQualifyApproval(row)
+    ? pass
+      ? passQualifyType.value
+      : rejectQualifyMode.value === 'ineligible'
+        ? '无资格'
+        : undefined
+    : undefined
+  await decideApproval({
+    id: row.id,
+    pass,
+    opinion,
+    fileUrls,
+    retireQualifyType: qualifyType,
+  })
+  const isTown = row.node.includes('镇经发') || isQualifyApproval(row)
+  const isMonthly = row.bizType.includes('月') || row.bizType.includes('清单')
+  if (pass) {
+    const isDeath = row.bizType.includes('死亡')
+    ElMessage.success(
+      isTown
+        ? '已通过,人员状态已更新为「新增」'
+        : isMonthly
+          ? '已通过,批次进入「已审核」,可由业务部门出盘'
+          : isDeath
+            ? '已通过,人员状态已更新为「死亡丧葬」'
+            : '已通过',
+    )
+  } else {
+    ElMessage.success(
+      isQualifyApproval(row)
+        ? rejectQualifyMode.value === 'ineligible'
+          ? '已认定无资格,已写入村告警,请村合作社处理'
+          : '已驳回补材料,已写入村告警,请村合作社补充后重新提交'
+        : isTown
           ? '已驳回(补充/修改),批次已退回村合作社'
           : isMonthly
             ? '已驳回(补充/修改),批次已退回居保匹配环节'
             : '已驳回(补充/修改),已退回业务受理',
-      )
-    }
-    detailVisible.value = false
-    await loadApprovals()
-  } finally {
-    deciding.value = false
+    )
   }
+  detailVisible.value = false
+  await loadApprovals()
+}
+
+async function townPassBlockReason(row: Approval, people?: Person[]): Promise<string | null> {
+  if (isSpecialApproval(row) || isQualifyApproval(row)) return null
+  if (!(isTownReview.value || row.node.includes('镇经发'))) return null
+  let list = people
+  if (!list) {
+    const batchId = row.batchId || batchIdOfApproval(row)
+    if (!batchId) return null
+    list = await fetchBatchMemberPeople(batchId, {
+      batchNo: row.batchNo,
+      town: row.town,
+      village: row.village,
+      pruneDefaults: false,
+    })
+  }
+  const ineligible = list.filter((p) => p.retireQualifyType === '无资格' || p.status === '无资格')
+  if (ineligible.length) {
+    return `「${row.village || row.title}」有 ${ineligible.length} 人已认定「无资格」,不能通过;请驳回由村取消该人员或补充说明。`
+  }
+  const pending = list.filter((p) => needsQualifyReview(p))
+  if (pending.length) {
+    const names = pending
+      .slice(0, 8)
+      .map((p) => p.name || p.id)
+      .join('、')
+    const extra = pending.length > 8 ? ` 等 ${pending.length} 人` : ''
+    return `「${row.village || row.title}」有 ${pending.length} 人退养资格尚未核验(${names}${extra}),请先在详情中点「核验资格」后再通过。`
+  }
+  return null
 }
 
 async function onDecide(row: Approval, pass: boolean) {
   if (pass) {
-    if (
-      (isTownReview.value || row.node.includes('镇经发')) &&
-      detailVisible.value &&
-      currentRow.value?.id === row.id &&
-      hasIneligibleMembers.value
-    ) {
-      ElMessage.warning(
-        `本批有 ${ineligibleMembers.value.length} 人已认定「无资格」,不能通过;请驳回由村取消该人员或补充说明。`,
-      )
+    const people =
+      detailVisible.value && currentRow.value?.id === row.id ? detailPeople.value : undefined
+    const block = await townPassBlockReason(row, people)
+    if (block) {
+      ElMessage.warning(block)
       return
     }
-    const unchecked =
-      (isTownReview.value || row.node.includes('镇经发')) &&
-      detailVisible.value &&
-      currentRow.value?.id === row.id
-        ? pendingQualifyCount.value
-        : 0
     passTarget.value = row
     passBatchMode.value = false
     passOpinion.value = '同意'
     passFileList.value = []
     passRawFiles.value = []
-    if (isTownReview.value || row.node.includes('镇经发')) {
-      passTip.value =
-        unchecked > 0
-          ? `仍有 ${unchecked} 人未完成资格核验。确认仍要通过「${row.title}」?通过后该村批次将进入「居保状态匹配」。`
-          : `确认通过「${row.title}」?通过后该村批次将进入「居保状态匹配」。`
+    if (isQualifyApproval(row) || isTownReview.value || row.node.includes('镇经发')) {
+      passTip.value = `确认通过「${row.title}」?村新提交的人员通过后变为「新增」;已在正常发放的人员只补登记退养资格。`
     } else if (isSpecialApproval(row)) {
       passTip.value = `确认通过「${row.title}」?特殊业务通过后将按类型回写人员状态(死亡登记通过后变为「死亡丧葬」)。`
     } else {
@@ -1048,15 +1240,22 @@ async function onDecide(row: Approval, pass: boolean) {
 }
 
 async function confirmPass() {
+  if (guardBusy(deciding)) return
   const opinion = passOpinion.value.trim() || '同意'
   deciding.value = true
   try {
     const fileUrls = await uploadApproveFiles(passRawFiles.value)
     if (passBatchMode.value) {
       for (const id of selectedIds.value) {
-        await decideApproval({ id, pass: true, opinion, fileUrls })
+        await decideApproval({
+          id,
+          pass: true,
+          opinion,
+          fileUrls,
+          retireQualifyType: isTownReview.value ? passQualifyType.value : undefined,
+        })
       }
-      ElMessage.success('批量通过成功,批次状态已更新')
+      ElMessage.success(isTownReview.value ? '批量资格审批通过' : '批量通过成功')
       selectedIds.value = []
       passVisible.value = false
       detailVisible.value = false
@@ -1064,16 +1263,13 @@ async function confirmPass() {
       return
     }
     if (passTarget.value) {
-      // 避免与 doDecide 重复切换 loading
-      deciding.value = false
       await doDecide(passTarget.value, true, opinion, fileUrls)
       passVisible.value = false
     }
   } catch (e) {
     ElMessage.error(e instanceof Error ? e.message : '上传或审批失败')
-    deciding.value = false
   } finally {
-    if (passBatchMode.value) deciding.value = false
+    deciding.value = false
   }
 }
 
@@ -1083,14 +1279,26 @@ async function confirmReject() {
     ElMessage.warning('请填写驳回理由')
     return
   }
+  if (guardBusy(deciding)) return
   deciding.value = true
   try {
     const fileUrls = await uploadApproveFiles(rejectRawFiles.value)
     if (rejectBatchMode.value) {
       for (const id of selectedIds.value) {
-        await decideApproval({ id, pass: false, opinion: reason, fileUrls })
+        await decideApproval({
+          id,
+          pass: false,
+          opinion: reason,
+          fileUrls,
+          retireQualifyType:
+            isTownReview.value
+              ? rejectQualifyMode.value === 'ineligible'
+                ? '无资格'
+                : '有土地退养'
+              : undefined,
+        })
       }
-      ElMessage.success('批量驳回成功,相关批次已退回修改')
+      ElMessage.success('批量驳回成功')
       selectedIds.value = []
       rejectVisible.value = false
       detailVisible.value = false
@@ -1098,15 +1306,13 @@ async function confirmReject() {
       return
     }
     if (rejectTarget.value) {
-      deciding.value = false
       await doDecide(rejectTarget.value, false, reason, fileUrls)
       rejectVisible.value = false
     }
   } catch (e) {
     ElMessage.error(e instanceof Error ? e.message : '上传或驳回失败')
-    deciding.value = false
   } finally {
-    if (rejectBatchMode.value) deciding.value = false
+    deciding.value = false
   }
 }
 
@@ -1120,6 +1326,17 @@ async function onBatchDecide(pass: boolean) {
     rejectVisible.value = true
     return
   }
+  if (isTownReview.value) {
+    for (const id of selectedIds.value) {
+      const row = list.value.find((r) => r.id === id)
+      if (!row) continue
+      const block = await townPassBlockReason(row)
+      if (block) {
+        ElMessage.warning(block)
+        return
+      }
+    }
+  }
   passBatchMode.value = true
   passTarget.value = null
   passOpinion.value = '同意'
@@ -1131,17 +1348,14 @@ async function onBatchDecide(pass: boolean) {
 
 async function loadApprovals() {
   loading.value = true
+  selectedIds.value = []
   try {
     const role = userStore.roleInfo
     const isPending = activeTab.value === 'pending'
     list.value = await fetchApprovals({
-      tab: isPending ? 'pending' : undefined,
+      tab: isPending ? 'pending' : 'history',
       node: approvalNode.value,
-      // 镇经发 / 分管领导:均限定本镇
       town: isTownReview.value || isLeaderReview.value ? role?.town : undefined,
-      // 镇经发:本月村合作社提交的月度批次名单
-      batchNo: isTownReview.value && isPending ? currentBatchNo.value : undefined,
-      bizTypeIncludes: isTownReview.value && isPending ? '月度批次' : undefined,
     })
     resetPage()
   } finally {
@@ -1149,13 +1363,27 @@ async function loadApprovals() {
   }
 }
 
-onMounted(loadApprovals)
+function onTabChange() {
+  filterStatus.value = ''
+  loadApprovals()
+}
+
+onMounted(async () => {
+  await syncScopeTown()
+  await loadApprovals()
+})
 
 watch(
   () => [userStore.currentRole, userStore.currentMonth],
-  () => {
-    resetTownVillage()
-    loadApprovals()
+  async () => {
+    filterBatchNo.value = ''
+    filterStatus.value = ''
+    if (isAdmin.value) resetTownVillage()
+    else {
+      filterVillage.value = ''
+      await syncScopeTown()
+    }
+    await loadApprovals()
   },
 )
 </script>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 273 - 546
src/views/base/BaseInfoView.vue


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

@@ -1,6 +1,6 @@
 <template>
   <div
-    class="dashboard-view"
+    class="dashboard-view page-stack"
     :class="{
       'dashboard-view--village': isVillage || isTown || isSocial || isLeader,
       'dashboard-view--district': isDistrictPolicy,
@@ -53,9 +53,16 @@
           <div>
             <h3>{{ overviewTitle }}</h3>
             <p>
-              统一批次 {{ data?.batchInfo.batchNo || '-' }} · 统计范围
-              <em>{{ data?.batchInfo.scope || '-' }}</em>
-              · {{ roleTip }}
+              <template v-if="isVillage || isTown">
+                统计范围
+                <em>{{ data?.batchInfo.scope || '-' }}</em>
+                · {{ roleTip }}
+              </template>
+              <template v-else>
+                全区月批 {{ data?.batchInfo.batchNo || '-' }} · 统计范围
+                <em>{{ data?.batchInfo.scope || '-' }}</em>
+                · {{ roleTip }}
+              </template>
             </p>
           </div>
         </div>
@@ -142,12 +149,12 @@
     <!-- 村合作社:左图右提示,占满可视区域避免大块留白 -->
     <div v-if="isVillage" class="village-workspace">
       <div class="village-workspace__charts">
-        <PagePanel title="本村近半年各批次发放人数">
+        <PagePanel title="本村近半年发放人数">
           <div class="village-workspace__chart-host">
             <ChartBox :option="villagePeopleBarOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel title="本村近半年各批次发放金额">
+        <PagePanel title="本村近半年发放金额">
           <div class="village-workspace__chart-host">
             <ChartBox :option="villageAmountBarOption" height="100%" />
           </div>
@@ -180,19 +187,19 @@
     <!-- 镇经发中心:左图右提示,布局对齐村合作社工作台 -->
     <div v-else-if="isTown" class="village-workspace">
       <div class="village-workspace__charts">
-        <PagePanel title="本批次各村发放金额">
+        <PagePanel title="本各村发放金额">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillageAmountOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel title="本批次各村发放人数">
+        <PagePanel title="本各村发放人数">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillagePeopleOption" height="100%" />
           </div>
         </PagePanel>
       </div>
 
-      <PagePanel title="初审核验要点" class="village-workspace__tips-panel">
+      <PagePanel title="资格审批要点" class="village-workspace__tips-panel">
         <div class="village-tips">
           <div
             v-for="(tip, idx) in data?.townReview?.tips ?? []"
@@ -218,12 +225,12 @@
     <!-- 镇社区事务受理中心:布局对齐村合作社 / 镇经发 -->
     <div v-else-if="isSocial" class="village-workspace">
       <div class="village-workspace__charts">
-        <PagePanel title="本批次各村发放金额">
+        <PagePanel title="本月本镇各村发放金额">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillageAmountOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel title="本批次各村发放人数">
+        <PagePanel title="本月本镇各村发放人数">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillagePeopleOption" height="100%" />
           </div>
@@ -253,15 +260,15 @@
       </PagePanel>
     </div>
 
-    <!-- 分管领导:布局对齐社区事务中心,侧重审批社区事务提交的批次 -->
+    <!-- 分管领导:审本镇月度清单与特殊业务 -->
     <div v-else-if="isLeader" class="village-workspace">
       <div class="village-workspace__charts">
-        <PagePanel title="本批次各村发放金额">
+        <PagePanel title="本月本镇各村发放金额">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillageAmountOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel title="本批次各村发放人数">
+        <PagePanel title="本月本镇各村发放人数">
           <div class="village-workspace__chart-host">
             <ChartBox :option="townVillagePeopleOption" height="100%" />
           </div>
@@ -311,7 +318,7 @@
             />
           </div>
         </PagePanel>
-        <PagePanel :title="isAgri ? '本批次退养资格结构' : '近半年各批次发放金额'">
+        <PagePanel :title="isAgri ? '本月退养资格结构' : '近半年发放金额'">
           <div class="district-workspace__chart-host">
             <ChartBox
               :option="isAgri ? districtQualifyPieOption : districtHalfYearAmountOption"
@@ -327,12 +334,12 @@
             />
           </div>
         </PagePanel>
-        <PagePanel title="本批次业务状态占比">
+        <PagePanel title="本业务状态占比">
           <div class="district-workspace__chart-host">
             <ChartBox :option="statusPieMonthOption" height="100%" />
           </div>
         </PagePanel>
-        <PagePanel :title="isAgri ? '近半年各批次人数' : '本年度业务状态占比'">
+        <PagePanel :title="isAgri ? '近半年发放人数' : '本年度业务状态占比'">
           <div class="district-workspace__chart-host">
             <ChartBox
               :option="isAgri ? districtHalfYearPeopleOption : statusPieYearOption"
@@ -408,6 +415,25 @@
           </el-table-column>
         </el-table>
       </PagePanel>
+
+      <PagePanel :title="districtTipsTitle">
+        <div class="village-tips">
+          <div
+            v-for="(tip, idx) in districtTips"
+            :key="idx"
+            class="village-tips__item"
+          >
+            <div class="village-tips__rail">
+              <span class="village-tips__index">{{ idx + 1 }}</span>
+              <span v-if="idx < districtTips.length - 1" class="village-tips__line" />
+            </div>
+            <div class="village-tips__card">
+              <div class="village-tips__title">{{ tip.title }}</div>
+              <p class="village-tips__desc">{{ tip.desc }}</p>
+            </div>
+          </div>
+        </div>
+      </PagePanel>
     </div>
 
     <!-- 其他角色:图表 + 待办告警 -->
@@ -416,10 +442,10 @@
         <PagePanel :title="data?.barChartTitle || '发放金额统计'">
           <ChartBox :option="roleBarOption" height="300px" />
         </PagePanel>
-        <PagePanel title="不同批次发放金额对比">
+        <PagePanel title="近半年发放金额对比">
           <ChartBox :option="batchCompareOption" height="300px" />
         </PagePanel>
-        <PagePanel title="本批次业务状态占比">
+        <PagePanel title="本业务状态占比">
           <ChartBox :option="statusPieMonthOption" height="300px" />
         </PagePanel>
         <PagePanel title="本年度业务状态占比">
@@ -457,6 +483,11 @@ import StatusTag from '@/components/common/StatusTag.vue'
 import { fetchDashboard, markWorkbenchTodoRead } from '@/api/biz'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
+import {
+  ADMIN_WORKBENCH_TIPS,
+  AGRI_WORKBENCH_TIPS,
+  HR_WORKBENCH_TIPS,
+} from '@/utils/buildDashboard'
 import type { AlertItem, ChartBarItem, DashboardData, TodoItem } from '@/types/biz'
 
 const router = useRouter()
@@ -503,31 +534,46 @@ const isDistrictPolicy = computed(() => isAgri.value || isHr.value || isAdmin.va
 
 /** 概览标题 */
 const overviewTitle = computed(() => {
-  if (isTown.value) return '本批次土地退养资格初审'
-  if (isSocial.value) return '本批次居保匹配与发放办理'
-  if (isLeader.value) return '本批次线上审批'
+  if (isVillage.value) return '本村人员与资格'
+  if (isTown.value) return '本镇退养资格审批'
+  if (isSocial.value) return '本镇居保匹配与发放办理'
+  if (isLeader.value) return '本镇月度清单线上审批'
   if (isAgri.value) return '全区土地退养资格监测'
   if (isHr.value) return '全区土地退养发放监测'
   if (isAdmin.value) return '全区业务数据监测'
-  return '本批次发放统计'
+  return '本发放统计'
 })
 
 /** 角色提示文案 */
 const roleTip = computed(() => {
   const role = userStore.roleInfo
   if (!role) return ''
-  if (role.key === 'admin') return '系统管理员全区可操作视角(按镇+村维度监测)'
-  if (role.key === 'agri') return '全区各镇各村土地资格审查视角(只读监测'
-  if (role.key === 'hr') return '全区各镇各村发放金额监测视角(只读)'
+  if (role.key === 'admin') return '运维全区月批与基础数据,不替代各镇业务审批'
+  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.key === 'social') return '本镇居保匹配与业务办理视角'
-  if (role.key === 'leader') return '本镇社区事务中心上级审批视角'
+  if (role.village) return '本村人员录入与提交资格(不看见批次)'
+  if (role.key === 'town') return '本镇按人审退养资格(不看见批次)'
+  if (role.key === 'social') return '本镇居保、清单、特殊业务与出盘回盘'
+  if (role.key === 'leader') return '审本镇月度清单与特殊业务'
   if (role.town) return '本镇数据视角'
   return role.label
 })
 
+const districtTips = computed(() => {
+  if (isAgri.value) return AGRI_WORKBENCH_TIPS
+  if (isHr.value) return HR_WORKBENCH_TIPS
+  if (isAdmin.value) return ADMIN_WORKBENCH_TIPS
+  return []
+})
+
+const districtTipsTitle = computed(() => {
+  if (isAgri.value) return '政策监测要点'
+  if (isHr.value) return '发放监测要点'
+  if (isAdmin.value) return '运维要点'
+  return '工作要点'
+})
+
 /** 5.1.1.1 核心数据指标:总人数、本月新增/减少、本月发放金额(按镇/月由 workbench 汇总) */
 const statItems = computed(() => {
   const s = data.value?.stats
@@ -563,20 +609,53 @@ function goAlert(alertId?: string) {
   }
 }
 
-/** 批次概览指标(按角色侧重) */
+/** 概览指标(按角色侧重) */
 const batchMetrics = computed(() => {
   const info = data.value?.batchInfo
+  if (isVillage.value) {
+    const s = data.value?.stats
+    return [
+      {
+        label: '本村在册',
+        value: s?.normal ?? info?.peopleCount ?? '-',
+        unit: '人',
+        icon: 'User',
+        tone: 'tone-green',
+      },
+      {
+        label: '本月新增',
+        value: s?.monthIncrease ?? 0,
+        unit: '人',
+        icon: 'Checked',
+        tone: 'tone-plain',
+      },
+      {
+        label: '本月减少',
+        value: s?.monthDecrease ?? 0,
+        unit: '人',
+        icon: 'Bell',
+        tone: 'tone-gold',
+      },
+      {
+        label: '预计发放',
+        value: s ? formatAmount(s.amount) : info ? formatAmount(info.amount) : '-',
+        unit: '元',
+        icon: 'Wallet',
+        tone: 'tone-gold',
+      },
+    ]
+  }
   if (isTown.value) {
     const review = data.value?.townReview
     return [
       {
-        label: '统一批次号',
-        value: info?.batchNo ?? '-',
-        icon: 'Ticket',
+        label: '发放月份',
+        value: userStore.currentMonth || '-',
+        icon: 'Calendar',
         tone: 'tone-gold',
       },
       {
-        label: '待初审新增',
+        label: '待资格审批',
         value: review?.pendingCount ?? 0,
         unit: '人',
         icon: 'User',
@@ -602,7 +681,7 @@ const batchMetrics = computed(() => {
     const sw = data.value?.socialWorkbench
     return [
       {
-        label: '统一批次号',
+        label: '全区月批',
         value: info?.batchNo ?? '-',
         icon: 'Ticket',
         tone: 'tone-gold',
@@ -633,7 +712,7 @@ const batchMetrics = computed(() => {
     const lw = data.value?.leaderWorkbench
     return [
       {
-        label: '统一批次号',
+        label: '全区月批',
         value: info?.batchNo ?? '-',
         icon: 'Ticket',
         tone: 'tone-gold',
@@ -664,7 +743,7 @@ const batchMetrics = computed(() => {
     const d = data.value?.districtOverview
     return [
       {
-        label: '统一批次号',
+        label: '全区月批',
         value: info?.batchNo ?? '-',
         icon: 'Ticket',
         tone: 'tone-gold',
@@ -695,7 +774,7 @@ const batchMetrics = computed(() => {
     const d = data.value?.districtOverview
     return [
       {
-        label: '统一批次号',
+        label: '全区月批',
         value: info?.batchNo ?? '-',
         icon: 'Ticket',
         tone: 'tone-gold',
@@ -724,7 +803,7 @@ const batchMetrics = computed(() => {
   }
   return [
     {
-      label: '统一批次号',
+      label: '全区月批',
       value: info?.batchNo ?? '-',
       icon: 'Ticket',
       tone: 'tone-gold',
@@ -989,7 +1068,7 @@ function buildPieOption(
 }
 
 const statusPieMonthOption = computed(() =>
-  buildPieOption(data.value?.statusPieMonth ?? [], '本批次状态'),
+  buildPieOption(data.value?.statusPieMonth ?? [], '本状态'),
 )
 const statusPieYearOption = computed(() =>
   buildPieOption(data.value?.statusPieYear ?? [], '年度状态'),

+ 11 - 12
src/views/disk/DiskReturnView.vue

@@ -1,16 +1,16 @@
 <template>
-  <div class="disk-return-view">
+  <div class="page-fill disk-return-view">
     <PagePanel title="回盘信息确认">
       <template #extra>
         <el-button type="primary" size="small" @click="openUploadDialog">回盘文件上传</el-button>
       </template>
 
-      <p class="disk-return-view__tip">
+      <p class="page-tip">
         <template v-if="isAdmin">
-          系统管理员可查看全区各镇回盘记录,请按街镇筛选后操作
+          查看各镇回盘与归档。出盘、回盘、归档由对应镇社区事务办理
         </template>
         <template v-else>
-          出盘后在此上传回盘文件;回盘确认完成后点「归档」将批次标为已归档、完成本月闭环(不再出现在工作台待办)。失败人员可查看原因并处理告警。
+          分管领导通过并出盘后,在此上传回盘并确认;完成后点「归档」闭环。失败人员可查看原因并处理告警。
         </template>
       </p>
       <div v-if="isAdmin" class="query-panel disk-return-view__filter">
@@ -27,7 +27,8 @@
         </el-form>
       </div>
 
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
         <el-table-column prop="batchNo" label="批次号" width="100" align="center" />
         <el-table-column prop="town" label="街镇" width="90" />
         <el-table-column prop="village" label="村居" width="110" show-overflow-tooltip>
@@ -86,6 +87,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -217,6 +219,7 @@ import {
   uploadReturnDiskFile,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy } from '@/composables/useSubmitLock'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { formatAmount, maskBankCard, maskIdCard } from '@/utils/mask'
@@ -301,6 +304,7 @@ async function submitUpload() {
     return
   }
 
+  if (guardBusy(uploading)) return
   uploading.value = true
   try {
     const res = await uploadReturnDiskFile({
@@ -340,6 +344,7 @@ async function onArchive(row: DiskRecord) {
   } catch {
     return
   }
+  if (guardBusy(archivingId)) return
   archivingId.value = row.id
   try {
     await archiveToNextMonth(row.id)
@@ -368,6 +373,7 @@ function onFailDialogClosed() {
 }
 
 async function onConfirmPaid(row: DiskFailPerson) {
+  if (guardBusy(confirmingId)) return
   confirmingId.value = row.id
   try {
     const res = await confirmDiskFailPaid(row.id)
@@ -404,13 +410,6 @@ watch(
 </script>
 
 <style scoped lang="less">
-.disk-return-view__tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
-
 .disk-return-view__filter {
   margin-bottom: 10px;
   padding: 8px 12px 0;

+ 135 - 36
src/views/insurance/InsuranceMatchView.vue

@@ -1,6 +1,6 @@
 <template>
-  <div class="insurance-match-view">
-    <PagePanel title="本批次居保匹配列表">
+  <div class="page-fill insurance-match-view">
+    <PagePanel :title="panelTitle">
       <template #extra>
         <el-button
           :loading="exporting"
@@ -10,21 +10,36 @@
           导出结果
         </el-button>
         <el-button
+          v-if="!isHistoryTab"
           type="primary"
           :loading="matching"
-          :disabled="!selectedBatches.length"
+          :disabled="!selectedBatches.length || matching"
           @click="onBatchMatch"
         >
           批量匹配
         </el-button>
       </template>
-      <p class="insurance-match-view__tip">
-        与人社居保交叉核验本批人员资格。匹配后:全部通过可到「月补贴清单」提交分管领导;存在「匹配失败」须在本页「人工确认」处理完后再提交。已匹配完成的村居不会被「全选」勾中。
-        <template v-if="isAdmin">系统管理员可按街镇+村居筛选全区批次。</template>
+      <p class="page-tip">
+        <template v-if="isAdmin && isHistoryTab">
+          已提交领导及之后的各镇居保结果,只读。请按街镇筛选。
+        </template>
+        <template v-else-if="isHistoryTab">
+          已提交领导及之后的本镇居保结果,只读,不可再次匹配。
+        </template>
+        <template v-else-if="isAdmin">
+          每月 1 号全区月批生成后自动居保。本页按街镇查看匹配结果;失败须由对应镇社区事务人工确认后才能提交该镇月度清单。
+        </template>
+        <template v-else>
+          核验全区当月批次的本镇人员。失败须在本页人工确认后,才能提交月度清单;已匹配完成的不会被全选勾中。
+        </template>
       </p>
-      <div v-if="isAdmin" class="query-panel insurance-match-view__filter">
+      <el-tabs v-model="activeTab" @tab-change="onTabChange">
+        <el-tab-pane label="待办理" name="todo" />
+        <el-tab-pane label="历史记录" name="history" />
+      </el-tabs>
+      <div class="query-panel insurance-match-view__filter">
         <el-form :inline="true" size="small">
-          <el-form-item label="街镇">
+          <el-form-item v-if="isAdmin" label="街镇">
             <el-select
               v-model="filterTown"
               placeholder="全部"
@@ -40,12 +55,27 @@
               <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="filterBatchNo"
+              placeholder="如 202608"
+              clearable
+              style="width: 130px"
+              @keyup.enter="applyFilter"
+            />
+          </el-form-item>
+          <el-form-item label="流转状态">
+            <el-select v-model="filterStage" placeholder="全部" clearable style="width: 150px">
+              <el-option v-for="s in stageOptions" :key="s" :label="s" :value="s" />
+            </el-select>
+          </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>
       </div>
+      <div class="page-fill__table">
       <el-table
         ref="tableRef"
         v-loading="batchLoading"
@@ -53,10 +83,12 @@
         border
         stripe
         size="small"
+        height="100%"
         row-key="id"
         @selection-change="onSelectionChange"
       >
         <el-table-column
+          v-if="!isHistoryTab"
           type="selection"
           width="42"
           align="center"
@@ -64,10 +96,10 @@
         />
         <el-table-column prop="batchNo" label="统一批次号" width="100" align="center" />
         <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column label="村居" width="110">
+        <el-table-column label="范围" width="110">
           <template #default="{ row }">
             <el-button
-              v-if="row.stage === '比对结果已生成'"
+              v-if="canViewMatchResult(row as VillageBatch)"
               link
               type="primary"
               @click="openVillageResult(row as VillageBatch)"
@@ -111,12 +143,14 @@
               link
               type="success"
               size="small"
+              :loading="matching"
+              :disabled="matching"
               @click="onMatchOne(row as VillageBatch)"
             >
               匹配
             </el-button>
             <el-button
-              v-else-if="row.stage === '比对结果已生成'"
+              v-else-if="canViewMatchResult(row as VillageBatch)"
               link
               type="success"
               size="small"
@@ -125,7 +159,7 @@
               匹配结果
             </el-button>
             <el-button
-              v-if="row.stage === '比对结果已生成'"
+              v-if="canViewMatchResult(row as VillageBatch)"
               link
               type="primary"
               size="small"
@@ -137,6 +171,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination
         v-model:page="batchPage"
         v-model:page-size="batchPageSize"
@@ -147,7 +182,7 @@
     <!-- 可研 5.1.4.1:任务态 / 失败清单 / 执行日志 -->
     <PagePanel v-if="lastTaskSummary || taskLogs.length" title="居保匹配任务执行情况">
       <p class="insurance-match-view__tip">
-        本区展示最近一次匹配任务的人数汇总、需人工处理名单及可审计的执行日志(含操作人与结果摘要)
+        最近一次匹配的人数汇总、待人工处理名单与执行日志
       </p>
       <div v-if="lastTaskSummary" class="insurance-match-view__task-summary">
         <el-tag type="info" effect="plain">
@@ -243,7 +278,7 @@
         <el-table-column label="操作" width="100" align="center" fixed="right">
           <template #default="{ row }">
             <el-button
-              v-if="row.insuranceStatus !== '匹配通过'"
+              v-if="!isHistoryTab && row.insuranceStatus !== '匹配通过'"
               link
               type="primary"
               size="small"
@@ -320,6 +355,7 @@ import {
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
+import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import BatchDetailDrawer from '@/components/biz/BatchDetailDrawer.vue'
 import { filterByRoleScope } from '@/utils/buildDashboard'
@@ -342,8 +378,22 @@ const {
   onTownChange,
   resetTownVillage,
   matchTownVillage,
+  loadVillages,
 } = useTownVillageOptions()
 
+const activeTab = ref('todo')
+const isHistoryTab = computed(() => activeTab.value === 'history')
+const filterBatchNo = ref('')
+const filterStage = ref('')
+const panelTitle = computed(() =>
+  isHistoryTab.value ? '居保匹配历史记录' : '全区月批居保匹配(本镇切片)',
+)
+const stageOptions = computed(() =>
+  isHistoryTab.value
+    ? ['分管领导审批', '待线下发放', '回盘信息确认', '已回盘确认', '已归档']
+    : ['居保状态匹配', '比对结果已生成'],
+)
+
 const batches = ref<VillageBatch[]>([])
 const results = ref<Person[]>([])
 const batchLoading = ref(false)
@@ -419,14 +469,23 @@ const resultDialogTitle = computed(() => {
   return `${b.town}${b.village}(批次 ${b.batchNo})居保匹配情况`
 })
 
+function isDistrictBatch(b: VillageBatch) {
+  return b.batchLevel === 'DISTRICT' || b.village === '全区' || (!b.town && !b.village)
+}
+
 const displayBatches = computed(() => {
   let list = batches.value
   const role = userStore.roleInfo
   if (role && !role.allData) {
     list = filterByRoleScope(list, role)
-  } else if (isAdmin.value) {
-    list = list.filter((b) => matchTownVillage(b))
   }
+  if (isAdmin.value || filterTown.value || filterVillage.value) {
+    // 全区月批没有街镇字段,按镇过滤时仍保留,结果接口再按本镇切片
+    list = list.filter((b) => isDistrictBatch(b) || matchTownVillage(b))
+  }
+  const batchNo = filterBatchNo.value.trim()
+  if (batchNo) list = list.filter((b) => b.batchNo.includes(batchNo))
+  if (filterStage.value) list = list.filter((b) => b.stage === filterStage.value)
   return list
 })
 
@@ -451,15 +510,33 @@ function applyFilter() {
 }
 
 function resetFilter() {
-  resetTownVillage()
+  filterBatchNo.value = ''
+  filterStage.value = ''
+  if (isAdmin.value) {
+    resetTownVillage()
+  } else {
+    filterVillage.value = ''
+  }
   resetBatchPage()
 }
 
+async function syncScopeTown() {
+  const town = userStore.roleInfo?.town
+  if (!isAdmin.value && town) {
+    filterTown.value = town
+    await loadVillages(town)
+  }
+}
+
 /** 已匹配完成的村不可勾选,全选时也不会被选中 */
 function isBatchSelectable(row: VillageBatch) {
   return row.stage === '居保状态匹配'
 }
 
+function canViewMatchResult(row: VillageBatch) {
+  return row.stage !== '居保状态匹配'
+}
+
 function onSelectionChange(rows: VillageBatch[]) {
   selectedBatches.value = rows
 }
@@ -472,6 +549,7 @@ async function onBatchMatch() {
     ElMessage.warning('请先勾选待匹配的村居')
     return
   }
+  if (guardBusy(matching)) return
   matching.value = true
   try {
     const res = (await triggerInsuranceMatch(ids)) as {
@@ -499,20 +577,22 @@ async function onBatchMatch() {
 }
 
 async function onMatchOne(row: VillageBatch) {
-  const res = (await triggerInsuranceMatch(row.id)) as {
-    lastSummary?: Record<string, unknown> | null
-  }
-  if (res?.lastSummary) {
-    lastTaskSummary.value = res.lastSummary
-  }
-  await loadTaskLogs(row.id)
-  const fail = Number(res?.lastSummary?.failCount || 0)
-  ElMessage.success(
-    fail > 0
-      ? `${row.village}匹配完成,有 ${fail} 人需处理`
-      : `${row.village}比对结果清单已生成`,
-  )
-  await loadBatches()
+  await runSubmit(matching, async () => {
+    const res = (await triggerInsuranceMatch(row.id)) as {
+      lastSummary?: Record<string, unknown> | null
+    }
+    if (res?.lastSummary) {
+      lastTaskSummary.value = res.lastSummary
+    }
+    await loadTaskLogs(row.id)
+    const fail = Number(res?.lastSummary?.failCount || 0)
+    ElMessage.success(
+      fail > 0
+        ? `${row.village}匹配完成,有 ${fail} 人需处理`
+        : `${row.village}比对结果清单已生成`,
+    )
+    await loadBatches()
+  })
 }
 
 async function openVillageResult(row: VillageBatch) {
@@ -554,6 +634,7 @@ function onManualDialogClosed() {
 
 async function submitManualConfirm(pass: boolean) {
   if (!manualTarget.value) return
+  if (guardBusy(manualSaving)) return
   manualSaving.value = true
   try {
     await confirmInsuranceManual(manualTarget.value.id, {
@@ -577,6 +658,7 @@ async function submitManualConfirm(pass: boolean) {
 
 async function onExportBatch(row: VillageBatch) {
   if (!row.id) return
+  if (guardBusy(exportingBatchId)) return
   exportingBatchId.value = row.id
   try {
     const res = await exportInsuranceMatch({ batchId: row.id })
@@ -600,6 +682,7 @@ async function onExport() {
     ElMessage.warning(isAdmin.value ? '请先选择街镇,或勾选单个批次导出' : '未获取到当前街镇')
     return
   }
+  if (guardBusy(exporting)) return
   exporting.value = true
   try {
     const res = await exportInsuranceMatch({ town })
@@ -615,23 +698,39 @@ async function onExport() {
 
 async function loadBatches() {
   batchLoading.value = true
+  selectedBatches.value = []
   try {
-    batches.value = await fetchInsuranceBatches()
+    batches.value = await fetchInsuranceBatches({
+      tab: isHistoryTab.value ? 'history' : 'todo',
+    })
     resetBatchPage()
   } finally {
     batchLoading.value = false
   }
 }
 
-onMounted(() => {
+function onTabChange() {
+  filterStage.value = ''
+  tableRef.value?.clearSelection()
   loadBatches()
+}
+
+onMounted(async () => {
+  await syncScopeTown()
+  await loadBatches()
 })
 
 watch(
   () => userStore.currentRole,
-  () => {
-    resetTownVillage()
-    loadBatches()
+  async () => {
+    filterBatchNo.value = ''
+    filterStage.value = ''
+    if (isAdmin.value) resetTownVillage()
+    else {
+      filterVillage.value = ''
+      await syncScopeTown()
+    }
+    await loadBatches()
   },
 )
 </script>

+ 56 - 22
src/views/monthly/MonthlyPaymentView.vue

@@ -1,17 +1,17 @@
 <template>
-  <div class="monthly-payment-view">
+  <div class="page-fill monthly-payment-view">
     <PagePanel title="月补贴清单">
       <template #extra>
         <el-button size="small" :disabled="!calcBatchId" @click="onShowCalcRounds">
           核算版本列表
         </el-button>
       </template>
-      <p class="monthly-payment-view__tip">
+      <p class="page-tip">
         <template v-if="isAdmin">
-          系统管理员可查看全区各镇各村月补贴批次,请按街镇+村居筛选后操作
+          查看各镇月补贴进度(镇统计,挂全区月批)。提交领导、出盘由对应镇社区事务办理;该镇特殊业务未齐时不可提交
         </template>
         <template v-else>
-          流程:居保处理完 →「待提交」点提交分管领导 →「待审核」领导通过后变「已审核」→ 出盘 → 回盘页上传回盘。明细姓名旁「新增/减少」相对上月同村批变化;历史批次不可再提交审核。核算锁定后禁止改金额,防乱操作
+          明细为全区月批的本镇切片。居保完成且本镇当月特殊业务全部通过后,提交分管领导;通过后可出盘
         </template>
       </p>
       <div v-if="isAdmin" class="query-panel monthly-payment-view__filter">
@@ -38,11 +38,12 @@
           </el-form-item>
         </el-form>
       </div>
-      <el-table v-loading="loading" :data="pagedList" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="loading" :data="pagedList" border stripe size="small" height="100%">
         <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
         <el-table-column prop="month" label="发放月份" width="100" align="center" />
         <el-table-column prop="town" label="街镇" width="90" />
-        <el-table-column prop="village" label="村居" width="90" />
+        <el-table-column prop="village" label="范围" width="90" />
         <el-table-column prop="peopleCount" label="人数" width="72" align="center" />
         <el-table-column label="正常金额" width="110" align="right">
           <template #default="{ row }">{{ formatAmount(row.normalAmount) }}</template>
@@ -117,11 +118,18 @@
               link
               type="success"
               size="small"
+              :disabled="pendingSpecialCount > 0"
               :loading="actingId === row.id"
               @click="onSubmitLeader(row as VillageMonthlyBatch)"
             >
               提交领导
             </el-button>
+            <span
+              v-if="isCurrentBatch(row as VillageMonthlyBatch) && row.status === '待提交' && pendingSpecialCount > 0"
+              class="muted"
+            >
+              本镇尚有 {{ pendingSpecialCount }} 笔特殊业务待审
+            </span>
             <!-- 本批次待审核 -->
             <span
               v-else-if="isCurrentBatch(row as VillageMonthlyBatch) && row.status === '待审核'"
@@ -146,6 +154,8 @@
               link
               type="primary"
               size="small"
+              :loading="actingId === row.id"
+              :disabled="!!actingId"
               @click="onExportFile(row as VillageMonthlyBatch)"
             >
               导出清单
@@ -155,6 +165,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
     </PagePanel>
 
@@ -390,6 +401,7 @@ import {
   fetchBatchDetail,
   fetchBatchMemberPeople,
   fetchVillageMonthlyBatches,
+  fetchPendingSpecialCount,
   issueVillageMonthlyDisk,
   listCalcRounds,
   lockCalc,
@@ -397,6 +409,7 @@ import {
   submitToLeader,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy, runSubmitId } from '@/composables/useSubmitLock'
 import { useTownVillageOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { sumBatchMemberAmounts } from '@/utils/batchAmounts'
@@ -419,6 +432,7 @@ const {
 const list = ref<VillageMonthlyBatch[]>([])
 const loading = ref(false)
 const actingId = ref('')
+const pendingSpecialCount = ref(0)
 const detailVisible = ref(false)
 const detailLoading = ref(false)
 const currentRow = ref<VillageMonthlyBatch | null>(null)
@@ -428,6 +442,8 @@ const isAdjustDetailBatch = computed(() =>
     batchNo: currentRow.value?.batchNo,
     month: currentRow.value?.month,
     remarks: (currentRow.value as any)?.remarks || (currentRow.value as any)?.remark,
+    batchLevel: (currentRow.value as any)?.batchLevel,
+    village: currentRow.value?.village,
   }),
 )
 
@@ -524,11 +540,12 @@ async function openDetail(row: VillageMonthlyBatch) {
   detailLoading.value = true
   try {
     detailPeople.value = row.id
-      ? await fetchBatchMemberPeople(row.id, {
+      ? await fetchBatchMemberPeople(row.parentBatchId || row.id, {
           batchNo: row.batchNo,
           town: row.town,
           village: row.village,
           pruneDefaults: false,
+          parentBatchId: row.parentBatchId,
         })
       : []
     resetDetailPage()
@@ -543,15 +560,22 @@ function onDetailClosed() {
 }
 
 async function onSubmitLeader(row: VillageMonthlyBatch) {
+  const pending = await fetchPendingSpecialCount(row.town).catch(() => pendingSpecialCount.value)
+  if (pending > 0) {
+    ElMessage.warning(`本镇尚有 ${pending} 笔特殊业务待分管领导审批,全部通过后才能提交月度清单`)
+    pendingSpecialCount.value = pending
+    return
+  }
   try {
     await ElMessageBox.confirm(
-      `确认将「${row.town}${row.village}」批次 ${row.batchNo} 提交分管领导审核?`,
+      `确认将「${row.town}」本镇清单 ${row.batchNo} 提交分管领导审核?`,
       '提交分管领导审核',
       { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
     )
   } catch {
     return
   }
+  if (guardBusy(actingId)) return
   actingId.value = row.id
   try {
     const res = (await submitToLeader(row.id)) as { ok?: boolean; message?: string }
@@ -576,6 +600,7 @@ async function onIssueDisk(row: VillageMonthlyBatch) {
   } catch {
     return
   }
+  if (guardBusy(actingId)) return
   actingId.value = row.id
   try {
     const res = (await issueVillageMonthlyDisk(row.id)) as {
@@ -607,12 +632,14 @@ async function onExportFile(row: VillageMonthlyBatch) {
     ElMessage.info('请先完成出盘后再导出发放清单')
     return
   }
-  const res = (await exportMonthlyFile(row.id)) as { ok?: boolean; fileName?: string; message?: string }
-  if (res?.ok === false) {
-    ElMessage.warning(res.message || '导出失败')
-    return
-  }
-  ElMessage.success(res?.fileName ? `已下载:${res.fileName}` : '发放清单已下载')
+  await runSubmitId(actingId, row.id, async () => {
+    const res = (await exportMonthlyFile(row.id)) as { ok?: boolean; fileName?: string; message?: string }
+    if (res?.ok === false) {
+      ElMessage.warning(res.message || '导出失败')
+      return
+    }
+    ElMessage.success(res?.fileName ? `已下载:${res.fileName}` : '发放清单已下载')
+  })
 }
 
 function formatCalcTime(v: unknown) {
@@ -625,10 +652,14 @@ function formatCalcTime(v: unknown) {
 
 async function refreshCalcLocked(batchId: string) {
   const detail = await fetchBatchDetail(batchId)
-  calcLockedMap.value[batchId] = String(detail?.remarks || '').includes('CALC_LOCKED|true')
+  calcLockedMap.value[batchId] = Boolean(
+    (detail as { calcLocked?: boolean } | undefined)?.calcLocked
+      || String(detail?.remarks || '').includes('CALC_LOCKED|true'),
+  )
 }
 
 async function onSnapshotCalc(row: VillageMonthlyBatch) {
+  if (guardBusy(actingId)) return
   actingId.value = `calc-${row.id}`
   try {
     await snapshotCalcRound(row.id)
@@ -668,13 +699,15 @@ async function onShowCalcRounds() {
 
 async function onToggleCalcLock(row: VillageMonthlyBatch) {
   const locked = !calcLockedMap.value[row.id]
-  try {
-    await lockCalc(row.id, locked)
-    calcLockedMap.value[row.id] = locked
-    ElMessage.success(locked ? '核算已锁定' : '核算已解锁')
-  } catch {
-    await refreshCalcLocked(row.id)
-  }
+  await runSubmitId(actingId, row.id, async () => {
+    try {
+      await lockCalc(row.id, locked)
+      calcLockedMap.value[row.id] = locked
+      ElMessage.success(locked ? '核算已锁定' : '核算已解锁')
+    } catch {
+      await refreshCalcLocked(row.id)
+    }
+  })
 }
 
 async function loadData() {
@@ -682,6 +715,7 @@ async function loadData() {
   try {
     const town = userStore.roleInfo?.allData ? undefined : userStore.roleInfo?.town
     list.value = await fetchVillageMonthlyBatches(town)
+    pendingSpecialCount.value = await fetchPendingSpecialCount(town).catch(() => 0)
     for (const row of list.value.slice(0, 20)) {
       if (['待提交', '待审核', '已审核'].includes(row.status)) {
         void refreshCalcLocked(row.id)

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

@@ -1,12 +1,13 @@
 <template>
-  <div class="reports-view">
-    <p class="reports-view__tip">{{ scopeTip }}</p>
-
-    <div class="reports-view__actions">
-      <el-button type="primary" :loading="exporting" @click="onExportSummary">导出月度汇总</el-button>
-      <el-button @click="onPrint">打印本页</el-button>
-      <el-button :loading="topicLoading" @click="loadTopics">刷新专题</el-button>
-      <el-button v-if="isDistrict" :loading="yoyLoading" @click="loadYoy">刷新同比环比</el-button>
+  <div class="reports-view page-stack">
+    <div class="reports-view__toolbar">
+      <p class="page-tip">{{ scopeTip }}</p>
+      <div class="reports-view__actions">
+        <el-button type="primary" :loading="exporting" @click="onExportSummary">导出月度汇总</el-button>
+        <el-button @click="onPrint">打印本页</el-button>
+        <el-button :loading="topicLoading" @click="loadTopics">刷新专题</el-button>
+        <el-button v-if="isDistrict" :loading="yoyLoading" @click="loadYoy">刷新同比环比</el-button>
+      </div>
     </div>
 
     <!-- 可研 5.1.9.4:区级同比/环比 -->
@@ -705,6 +706,7 @@ import {
   fetchYoy,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
@@ -794,28 +796,28 @@ const scopeTip = computed(() => {
   const role = userStore.roleInfo
   if (!role) return ''
   if (role.key === 'admin') {
-    return '当前为系统管理员视角:可查询与操作全区各街镇、各村数据;人员台账提供全量查询条件(含街镇+村居、账户、居保、退养资格等)。'
+    return '运维查询全区各街镇数据。资格由村提交、镇经发审批;发放由各镇社区事务与分管领导办理。'
   }
   if (role.key === 'agri') {
-    return '当前为区农委视角(镇经发中心上级):可查询全区各镇各村土地退养政策落实情况,人员台账查询条件对齐镇经发,侧重土地资格审查(只读)。'
+    return '区农委只读:查看全区土地退养资格落实(有土地/无土地有资格),不办理审批与发放。'
   }
   if (role.key === 'hr') {
-    return '当前为区人社局视角(镇社区事务受理中心上级):可查询全区各镇各村土地退养发放情况,人员台账查询条件对齐社区事务中心,侧重发放与居保相关指标(只读)。'
+    return '区人社局只读:查看全区发放金额、居保与告警,不关闭告警、不办理出盘。'
   }
-  if (role.allData) return '当前为区级视角:可查询与监督全区各街镇数据(只读)。'
+  if (role.allData) return '区级只读:查询与监督全区各街镇数据。'
   if (role.village) {
-    return `当前为村级视角:可查询本村(${lockedTown.value}${lockedVillage.value})数据。`
+    return `仅本村(${lockedTown.value}${lockedVillage.value})人员与发放查询。办理入口在「人员新增录入」提交资格。`
   }
   if (role.key === 'town') {
-    return `当前为镇经发中心视角:顶部为本镇(${lockedTown.value})下属各村合计;批次汇总与人员台账可分别按全部字段查询。`
+    return `本镇(${lockedTown.value})各村资格与人员台账。审批入口在「审批管理」按人审资格,不看见批次。`
   }
   if (role.key === 'social') {
-    return `当前为镇社区事务受理中心视角:布局与镇经发类似,本镇(${lockedTown.value})批次/人员台账额外展示金额类数据。`
+    return `本镇(${lockedTown.value})发放、金额与特殊业务排查。办理顺序:居保核验 → 特殊业务齐 → 提交领导 → 出盘回盘。`
   }
   if (role.key === 'leader') {
-    return `当前为分管领导视角:布局与社区事务中心类似,可查询本镇(${lockedTown.value})批次/人员台账及金额类数据,便于审批前核对。`
+    return `本镇(${lockedTown.value})清单与金额核对,便于审批前查看规模与异常。审批入口在「审批管理」。`
   }
-  if (role.town) return `当前为镇级视角:可查询本镇(${lockedTown.value})数据,街镇条件已锁定。`
+  if (role.town) return `仅本镇(${lockedTown.value})数据,街镇条件已锁定。`
   return ''
 })
 
@@ -1367,6 +1369,7 @@ async function loadYoy() {
 }
 
 async function onExportSummary() {
+  if (guardBusy(exporting)) return
   exporting.value = true
   try {
     const payMonth = (userStore.currentMonth || '').replace('-', '')
@@ -1425,11 +1428,25 @@ watch(
   }
 }
 
+.reports-view__toolbar {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  .page-tip {
+    margin: 0;
+    flex: 1;
+    min-width: 0;
+  }
+}
+
 .reports-view__actions {
   display: flex;
   flex-wrap: wrap;
   gap: 8px;
-  margin-bottom: 12px;
+  flex-shrink: 0;
 }
 
 .reports-view__yoy-meta {
@@ -1438,13 +1455,6 @@ watch(
   color: #8a7a66;
 }
 
-.reports-view__tip {
-  margin: 0 0 10px;
-  font-size: 12px;
-  color: @text-secondary;
-  line-height: 1.5;
-}
-
 .reports-view__inner-query {
   margin-bottom: 12px;
   padding: 10px 12px 2px;

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

@@ -1,9 +1,14 @@
 <template>
-  <div class="special-business-view">
+  <div class="page-fill page-fill--tabs special-business-view">
     <PagePanel title="特殊业务处理">
-      <!-- <p class="special-business-view__tip">
-        业务部门统一受理:死亡人员录入与状态更新、人员状态维护(停止/恢复/补发)、丧葬补贴、调标补发申请。
-      </p> -->
+      <p class="page-tip">
+        <template v-if="isAdmin">
+          请先选择街镇。特殊业务领导通过后可调整本月全区发放明细;该镇当月未全部通过前,不可提交月度清单给分管领导。
+        </template>
+        <template v-else>
+          死亡、丧葬、停发/恢复、调标补发、多发退款在此受理,提交分管领导审批。全部通过前不可提交本镇月度清单。1 号后新人要进本月发放,须经本页审批通过后加人。
+        </template>
+      </p>
 
       <el-tabs v-model="activeTab">
         <!-- 人员状态维护:按村 Tab + 行内操作 -->
@@ -22,7 +27,7 @@
                 </el-select>
               </el-form-item>
             </el-form>
-            <p class="special-business-view__sub-tip">
+            <p class="page-tip">
               系统管理员请先选择街镇,再按该镇下属村居 Tab 维护人员状态。
             </p>
           </div>
@@ -34,13 +39,14 @@
             @tab-change="onVillageTabChange"
           >
             <el-tab-pane v-for="v in villageTabs" :key="v" :label="v" :name="v">
+              <div v-if="activeVillage === v" class="page-fill__table">
               <el-table
-                v-if="activeVillage === v"
                 v-loading="peopleLoading"
                 :data="pagedPeople"
                 border
                 stripe
                 size="small"
+                height="100%"
               >
                 <el-table-column prop="name" label="姓名" width="80" />
                 <el-table-column label="身份证" min-width="160">
@@ -94,6 +100,7 @@
                   </template>
                 </el-table-column>
               </el-table>
+              </div>
               <TablePagination v-if="activeVillage === v" v-model:page="peoplePage" v-model:page-size="peoplePageSize"
                 :total="peopleTotal" />
             </el-tab-pane>
@@ -132,10 +139,11 @@
               死亡人员信息录入
             </el-button>
           </div>
-          <p class="special-business-view__sub-tip">
-            从本镇在册人员中选择并上传死亡证明后提交状态为「待分管领导审批」,领导通过后才将人员改为死亡丧葬。列表含审批中单据与历史已死亡人员。
+          <p class="page-tip">
+            从本镇在册人员中选择并上传死亡证明;待分管领导通过后改为死亡丧葬。
           </p>
-          <el-table v-loading="deathLoading" :data="pagedDeaths" border stripe size="small">
+          <div class="page-fill__table">
+          <el-table v-loading="deathLoading" :data="pagedDeaths" border stripe size="small" height="100%">
             <el-table-column prop="personName" label="姓名" width="90" />
             <el-table-column label="身份证" min-width="150">
               <template #default="{ row }">{{ maskIdCard(row.idCard) }}</template>
@@ -158,6 +166,7 @@
               </template>
             </el-table-column>
           </el-table>
+          </div>
           <TablePagination v-model:page="deathPage" v-model:page-size="deathPageSize" :total="deathTotal" />
         </el-tab-pane>
 
@@ -189,10 +198,11 @@
               发起丧葬补贴申请
             </el-button>
           </div>
-          <p class="special-business-view__sub-tip">
-            仅展示本镇丧葬补贴特殊业务;发起时须从本镇在册人员中选择。
+          <p class="page-tip">
+            仅展示本镇丧葬补贴;发起时须从本镇在册人员中选择。
           </p>
-          <el-table v-loading="loading" :data="pagedFuneral" border stripe size="small">
+          <div class="page-fill__table">
+          <el-table v-loading="loading" :data="pagedFuneral" border stripe size="small" height="100%">
             <el-table-column prop="town" label="街镇" width="90" />
             <el-table-column prop="village" label="村居" width="90" />
             <el-table-column prop="personName" label="人员姓名" width="90" />
@@ -215,6 +225,7 @@
               </template>
             </el-table-column>
           </el-table>
+          </div>
           <TablePagination v-model:page="funeralPage" v-model:page-size="funeralPageSize" :total="funeralTotal" />
         </el-tab-pane>
 
@@ -236,7 +247,8 @@
               <div class="refund-stat-card__value">{{ item.value }}</div>
             </div>
           </div>
-          <el-table v-loading="loading" :data="pagedRefunds" border stripe size="small">
+          <div class="page-fill__table">
+          <el-table v-loading="loading" :data="pagedRefunds" border stripe size="small" height="100%">
             <el-table-column prop="town" label="街镇" width="90" />
             <el-table-column prop="village" label="村居" width="90" />
             <el-table-column prop="personName" label="人员" width="90" />
@@ -269,6 +281,7 @@
               </template>
             </el-table-column>
           </el-table>
+          </div>
           <TablePagination v-model:page="refundPage" v-model:page-size="refundPageSize" :total="refundTotal" />
         </el-tab-pane>
 
@@ -307,8 +320,9 @@
             <el-tabs v-model="adjustActiveVillage" type="card" class="special-business-view__village-tabs"
               @tab-change="onAdjustVillageTabChange">
               <el-tab-pane v-for="v in adjustVillageTabs" :key="v" :label="v" :name="v">
-                <el-table v-if="adjustActiveVillage === v" v-loading="adjustLoading" :data="pagedAdjustRoster" border
-                  stripe size="small">
+                <div v-if="adjustActiveVillage === v" class="page-fill__table">
+                <el-table v-loading="adjustLoading" :data="pagedAdjustRoster" border
+                  stripe size="small" height="100%">
                   <el-table-column prop="batchNo" label="批次号" width="88" align="center" />
                   <el-table-column prop="name" label="姓名" width="80" />
                   <el-table-column label="身份证" min-width="160">
@@ -330,6 +344,7 @@
                   </el-table-column>
                   <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
                 </el-table>
+                </div>
                 <TablePagination v-if="adjustActiveVillage === v" v-model:page="adjustRosterPage"
                   v-model:page-size="adjustRosterPageSize" :total="adjustRosterTotal" />
               </el-tab-pane>
@@ -866,6 +881,7 @@ import {
   uploadBizFile,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { filterByRoleScope } from '@/utils/buildDashboard'
@@ -876,7 +892,7 @@ import {
   resultLabelOf,
   roleLabelOf,
 } from '@/utils/statusLabels'
-import { isPositiveAmount, isValidIdCardOptional } from '@/utils/validators'
+import { explainIdCard, isPositiveAmount, isValidIdCardOptional } from '@/utils/validators'
 import type { AdjustReissuePerson, DeathRecord, Person, ProcessLogItem, SpecialBiz } from '@/types/biz'
 
 const userStore = useUserStore()
@@ -1578,6 +1594,7 @@ function openRefundProgress(row: SpecialBiz) {
 
 async function confirmRefundProgress() {
   if (!refundTarget.value || !refundNextStage.value) return
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     await advanceRefundProgress(refundTarget.value.id, refundNextStage.value)
@@ -1626,7 +1643,7 @@ async function onCreateDeath() {
     return
   }
   if (!isValidIdCardOptional(deathForm.heirIdCard)) {
-    ElMessage.warning('继承人身份证号格式不正确(选填,若填写须为合法证件号)')
+    ElMessage.warning(explainIdCard(deathForm.heirIdCard, '继承人身份证号') || '继承人身份证号格式不正确')
     return
   }
   const heirBank = deathForm.heirBankCard.trim()
@@ -1634,6 +1651,7 @@ async function onCreateDeath() {
     ElMessage.warning('继承人银行卡号须为 12–22 位数字')
     return
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     const uploaded: string[] = []
@@ -1742,6 +1760,7 @@ async function confirmStop() {
     return
   }
   if (!stopTarget.value) return
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     const uploaded: string[] = []
@@ -1773,6 +1792,7 @@ async function onSubmitReissue() {
     ElMessage.warning('请填写补发原因')
     return
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     // 只走 createSpecialBiz 一次;勿再调 applyPersonStatusAction(其内会对 SUPPLEMENT 再 POST /special-biz,导致双单)
@@ -1803,13 +1823,14 @@ async function onTriggerAdjustReissue() {
   }
   try {
     await ElMessageBox.confirm(
-      `确认发起本镇(${town || '全区'})调标补发申请?将自动在月补贴清单生成各村批次,并按村展示调标补发名单。`,
+      `确认发起本镇(${town || '全区'})调标补发申请?差额将挂到当年/当月全区批次补发明细,不再新建村批。`,
       '发起调标补发申请',
       { type: 'warning', confirmButtonText: '确认发起', cancelButtonText: '取消' },
     )
   } catch {
     return
   }
+  if (guardBusy(adjustTriggering)) return
   adjustTriggering.value = true
   try {
     const res = (await triggerAdjustReissue(town)) as {
@@ -1873,6 +1894,7 @@ async function onCreateSpecial() {
     ElMessage.warning('街镇/村居不完整,请重新选择人员')
     return
   }
+  if (guardBusy(saving)) return
   saving.value = true
   try {
     let fileUrls: string | undefined
@@ -2010,6 +2032,7 @@ watch(
     flex-wrap: wrap;
     gap: 10px;
     margin-bottom: 12px;
+    flex-shrink: 0;
   }
 
   .refund-stat-card {
@@ -2041,6 +2064,7 @@ watch(
   &__filter {
     margin-bottom: 10px;
     padding: 8px 12px 0;
+    flex-shrink: 0;
   }
 
   &__sub-tip {
@@ -2048,6 +2072,7 @@ watch(
     font-size: 12px;
     color: @text-secondary;
     line-height: 1.5;
+    flex-shrink: 0;
   }
 
   &__toolbar {
@@ -2055,10 +2080,33 @@ watch(
     flex-wrap: wrap;
     gap: 8px;
     margin-bottom: 10px;
+    flex-shrink: 0;
   }
 
   &__village-tabs {
-    margin-bottom: 10px;
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    margin-bottom: 0;
+
+    :deep(.el-tabs__header) {
+      flex-shrink: 0;
+    }
+
+    :deep(.el-tabs__content) {
+      flex: 1;
+      min-height: 0;
+      overflow: hidden;
+    }
+
+    :deep(.el-tab-pane) {
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
   }
 
   &__field-tip {

+ 18 - 16
src/views/status/StatusMaintenanceView.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="status-maintenance-view">
+  <div class="page-fill status-maintenance-view">
     <PagePanel title="补助标准管理">
       <template #extra>
         <el-button
@@ -17,15 +17,15 @@
         type="info"
         :closable="false"
         show-icon
-        class="status-maintenance-view__policy-tip"
-        title="标准变更默认需分管领导审批后生效;系统管理员需直改时,请在调整原因或备注中包含 ALLOW_DIRECT。"
+        class="page-alert"
+        title="标准变更默认需分管领导审批后生效。调标后由镇社区事务发起调标补发,通过后纳入发放。管理员直改走独立开关。"
       />
       <el-alert
         v-if="pendingTip"
         type="warning"
         :closable="false"
         show-icon
-        class="status-maintenance-view__policy-tip"
+        class="page-alert"
         :title="pendingTip"
       />
 
@@ -50,7 +50,8 @@
         </el-form>
       </div>
 
-      <el-table v-loading="standardLoading" :data="pagedStandards" border stripe size="small">
+      <div class="page-fill__table">
+      <el-table v-loading="standardLoading" :data="pagedStandards" border stripe size="small" height="100%">
         <el-table-column prop="town" label="街镇" width="100" />
         <el-table-column prop="year" label="年度" width="80" align="center" />
         <el-table-column label="月补助(元)" width="110" align="right">
@@ -102,6 +103,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
       <TablePagination
         v-model:page="standardPage"
         v-model:page-size="standardPageSize"
@@ -221,6 +223,7 @@ import PagePanel from '@/components/common/PagePanel.vue'
 import TablePagination from '@/components/common/TablePagination.vue'
 import { createStandard, deleteStandard, fetchStandardPendingTip, fetchStandards, updateStandard } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useDynamicRegionOptions } from '@/composables/useTownVillageOptions'
 import { useUserStore } from '@/stores/user'
 import { formatAmount } from '@/utils/mask'
@@ -413,6 +416,7 @@ async function submitForm() {
     }
   }
 
+  if (guardBusy(submitting)) return
   submitting.value = true
   try {
     if (formMode.value === 'create') {
@@ -469,13 +473,15 @@ async function onDeleteStandard(row: SubsidyStandard) {
   } catch {
     return
   }
-  const res = await deleteStandard({ id: row.id, rawId: row.rawId })
-  if (!res.ok) {
-    ElMessage.warning(res.message || '删除失败')
-    return
-  }
-  ElMessage.success(res.message || '已删除')
-  await loadStandards()
+  await runSubmit(submitting, async () => {
+    const res = await deleteStandard({ id: row.id, rawId: row.rawId })
+    if (!res.ok) {
+      ElMessage.warning(res.message || '删除失败')
+      return
+    }
+    ElMessage.success(res.message || '已删除')
+    await loadStandards()
+  })
 }
 
 async function loadStandards() {
@@ -519,10 +525,6 @@ watch(
     padding: 8px 12px 0;
   }
 
-  &__policy-tip {
-    margin-bottom: 10px;
-  }
-
   &__field-tip {
     margin: 4px 0 0;
     font-size: 12px;

+ 27 - 16
src/views/system/SystemAdminView.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="system-admin-view">
+  <div class="system-admin-view page-stack">
     <StatCards :items="overviewItems" />
 
     <PagePanel title="数据运维">
@@ -7,8 +7,9 @@
         <div class="system-admin-view__reset-text">
           <strong>恢复初始化</strong>
           <p>
-            清空历史批次、发放明细、流程/审批日志、告警、批次统计等业务脏数据;
-            <em>保留行政区划、人员底数、金额标准</em>。此操作不可撤销,请谨慎使用。
+            清空历史批次、发放明细、流程/审批日志(含退养资格已审)、告警、批次统计;
+            人员底数保留,但业务状态、居保、退养资格会回到待提交。
+            <em>保留行政区划、金额标准</em>。此操作不可撤销,请谨慎使用。
           </p>
         </div>
         <el-button type="danger" :loading="resetting" @click="onFactoryReset">
@@ -61,8 +62,8 @@
     </PagePanel>
 
     <PagePanel title="登录日志">
-      <p class="system-admin-view__tip">
-        记录角色切换/登录与登出审计(含客户端 IP)。用于安全追溯,不展示内部枚举码
+      <p class="page-tip">
+        登录/登出审计(含客户端 IP)。账号与校验规则在此维护;不替代各镇资格审批与发放办理
       </p>
       <el-table v-loading="loginLoading" :data="pagedLoginLogs" border stripe size="small">
         <el-table-column prop="time" label="时间" width="150" />
@@ -116,6 +117,7 @@
       <el-form v-loading="rulesLoading" label-width="200px" size="small">
         <el-form-item label="身份证格式校验">
           <el-switch v-model="validateRules.idCardValidate" />
+          <span class="system-admin-view__hint">开启后按国标校验 15/18 位及 18 位末位校验码,失败返回具体原因</span>
         </el-form-item>
         <el-form-item label="银行卡必填校验">
           <el-switch v-model="validateRules.bankCardRequired" />
@@ -214,6 +216,7 @@ import {
   setAccountStatus,
 } from '@/api/biz'
 import { useClientPagination } from '@/composables/useClientPagination'
+import { guardBusy, runSubmit } from '@/composables/useSubmitLock'
 import { useUserStore } from '@/stores/user'
 import { processActionLabelOf, resultLabelOf } from '@/utils/statusLabels'
 import type { AccountItem, OpLog } from '@/types/biz'
@@ -304,6 +307,7 @@ async function onSaveAccount() {
     accountDialogVisible.value = false
     return
   }
+  if (guardBusy(accountSaving)) return
   accountSaving.value = true
   try {
     await saveAccount({
@@ -321,10 +325,12 @@ async function onSaveAccount() {
 }
 
 async function onToggleAccountStatus(row: AccountItem) {
-  const enabled = row.status !== '启用'
-  await setAccountStatus(String(row.id), enabled)
-  ElMessage.success(enabled ? '已启用' : '已禁用')
-  await loadAccounts()
+  await runSubmit(accountSaving, async () => {
+    const enabled = row.status !== '启用'
+    await setAccountStatus(String(row.id), enabled)
+    ElMessage.success(enabled ? '已启用' : '已禁用')
+    await loadAccounts()
+  })
 }
 
 async function onResetPassword(row: AccountItem) {
@@ -334,14 +340,17 @@ async function onResetPassword(row: AccountItem) {
       cancelButtonText: '取消',
       inputValue: '123456',
     })
-    await resetAccountPassword(String(row.id), value || '123456')
-    ElMessage.success('密码已重置')
+    await runSubmit(accountSaving, async () => {
+      await resetAccountPassword(String(row.id), value || '123456')
+      ElMessage.success('密码已重置')
+    })
   } catch {
     /* cancel */
   }
 }
 
 async function onExportOpLogs() {
+  if (guardBusy(exportingLogs)) return
   exportingLogs.value = true
   try {
     const res = await exportOpLogs()
@@ -367,6 +376,7 @@ async function onCleanupOpLogs() {
   } catch {
     return
   }
+  if (guardBusy(cleaningLogs)) return
   cleaningLogs.value = true
   try {
     const res = await cleanupOpLogs(days)
@@ -378,6 +388,7 @@ async function onCleanupOpLogs() {
 }
 
 async function onSaveValidateRules() {
+  if (guardBusy(savingRules)) return
   savingRules.value = true
   try {
     await saveValidateRules({ ...validateRules })
@@ -394,7 +405,7 @@ async function onFactoryReset() {
   }
   try {
     await ElMessageBox.confirm(
-      '将清空全部批次、发放明细、审批/流程日志与告警,保留区划与人员底数。确定继续?',
+      '将清空全部批次、发放明细、审批/流程日志(含退养资格已审)与告警;人员资格与业务状态回到待提交。确定继续?',
       '恢复初始化确认',
       { type: 'warning', confirmButtonText: '确认清空', cancelButtonText: '取消' },
     )
@@ -412,12 +423,13 @@ async function onFactoryReset() {
   } catch {
     return
   }
+  if (guardBusy(resetting)) return
   resetting.value = true
   try {
     const res = await factoryResetBusinessData()
     const d = (res.detail || {}) as Record<string, unknown>
     ElMessage.success(
-      `${res.message || '已恢复初始化'}(共销毁 ${res.destroyedTotal} 条;明细 ${d.paymentDetail ?? '-'} / 批次 ${d.batch ?? '-'} / 流程 ${d.processLog ?? '-'} / 告警 ${d.alert ?? '-'})`,
+      `${res.message || '已恢复初始化'}(共销毁 ${res.destroyedTotal} 条;明细 ${d.paymentDetail ?? '-'} / 批次 ${d.batch ?? '-'} / 流程 ${d.processLog ?? '-'} / 告警 ${d.alert ?? '-'} / 人员状态重置 ${d.personnelReset ?? '-'})`,
     )
     await loadLogs()
   } catch (e: unknown) {
@@ -495,11 +507,10 @@ onMounted(() => {
   flex-wrap: wrap;
 }
 
-.system-admin-view__tip {
-  margin: 0 0 10px;
+.system-admin-view__hint {
+  margin-left: 10px;
   font-size: 12px;
   color: #8a7a66;
-  line-height: 1.5;
 }
 
 .system-admin-view__reset-text {

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä