Parcourir la source

替换静态数据或测试数据

DESKTOP-6LTVLN7\Liumouren il y a 1 mois
Parent
commit
0c9105877f

+ 1 - 1
public/config/config.js

@@ -18,7 +18,7 @@ window.AppConfig = {
   requestTimeout: 15000,
   // 业务 API 前缀(开发默认 /api,由 Vite 代理到 http://localhost:8088/sjnmtybt)
   apiBase: '/api',
-  // true:走本地 mock;false:对接 sjnmtybt-server 真实接口
+  // 必须为 false:业务数据统一走 sjnmtybt-server → DMS,禁止前端 mock 造假
   useMock: false,
   // 演示用当前角色(后续改为登录接口写入;页面切换后会写入 localStorage,刷新不丢)
   currentRole: 'admin',

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

@@ -143,11 +143,23 @@ export function mapPerson(raw: Record<string, unknown>, batchNoHint?: string): P
   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 (remarks.includes('无土地')) retireQualifyType = '无土地有资格'
-  else if (remarks.includes('有土地') || remarks.includes('资格已核验')) retireQualifyType = '有土地退养'
+  if (qualifyCode === 'WITHOUT_LAND' || qualifyCode === '无土地有资格') {
+    retireQualifyType = '无土地有资格'
+  } else if (qualifyCode === 'WITH_LAND' || qualifyCode === '有土地退养') {
+    retireQualifyType = '有土地退养'
+  } else if (remarks.includes('无土地') || remarks.includes('无承包地')) {
+    retireQualifyType = '无土地有资格'
+  } else if (remarks.includes('有土地') || remarks.includes('资格已核验')) {
+    retireQualifyType = '有土地退养'
+  }
+  // 已核验资格时,列表状态展示为「资格已核验」(业务状态仍可能是 NORMAL)
+  if (retireQualifyType && (status === '正常在库' || status === '草稿' || !status)) {
+    status = '资格已核验'
+  }
 
   return {
     id: String(raw.id || '') as unknown as number,

+ 388 - 135
src/api/sjnmtybt/realBiz.ts

@@ -4,7 +4,15 @@
  */
 import { del, get, post, put } from '@/utils/request'
 import { getAppConfig } from '@/utils/appConfig'
-import { buildDashboardByRole } from '@/utils/buildDashboard'
+import {
+  FLOW_STAGES,
+  getBarChartTitle,
+  getDashboardScopeLabel,
+  LEADER_WORKBENCH_TIPS,
+  SOCIAL_WORKBENCH_TIPS,
+  TOWN_REVIEW_TIPS,
+  VILLAGE_WORKBENCH_TIPS,
+} from '@/utils/buildDashboard'
 import type {
   AccountItem,
   AlertItem,
@@ -156,7 +164,183 @@ async function buildTownVillageChartsFromApi(townId: string, payMonth: string) {
   }
 }
 
-/** —— 工作台 —— */
+function emptyDashboard(role: ReturnType<typeof getAppConfig>['roles'][0], payMonth: string): DashboardData {
+  const isDistrict = Boolean(role.allData)
+  return {
+    stats: { normal: 0, pending: 0, alert: 0, amount: 0 },
+    batchInfo: {
+      batchNo: payMonth,
+      scope: getDashboardScopeLabel(role),
+      peopleCount: 0,
+      amount: 0,
+      todoCount: 0,
+      alertCount: 0,
+    },
+    barChartTitle: getBarChartTitle(role),
+    workflow: [...FLOW_STAGES],
+    workflowActive: 0,
+    townAmountChart: [],
+    batchCompareChart: [],
+    statusPieMonth: [],
+    statusPieYear: [],
+    todos: [],
+    recentAlerts: [],
+    villageTips: role.village ? VILLAGE_WORKBENCH_TIPS : undefined,
+    districtOverview: isDistrict
+      ? {
+          focus: role.key === 'agri' ? 'qualify' : 'amount',
+          townCount: 0,
+          villageCount: 0,
+          peopleCount: 0,
+          amount: 0,
+          withLandCount: 0,
+          withoutLandCount: 0,
+          qualifiedCount: 0,
+          townPeopleChart: [],
+          townAmountChart: [],
+          townWithLandChart: [],
+          townWithoutLandChart: [],
+          qualifyPie: [],
+          halfYearPeopleChart: [],
+          halfYearAmountChart: [],
+          townSummary: [],
+        }
+      : undefined,
+  }
+}
+
+type WorkbenchApi = {
+  summary?: Record<string, unknown> & {
+    totalPeople?: number
+    totalAmount?: number | string
+    pendingAlertCount?: number
+    townStats?: BatchStatRow[]
+  }
+  region?: RegionDashRow
+  personnelCount?: number
+  withLandCount?: number
+  withoutLandCount?: number
+  qualifiedCount?: number
+  pendingTownApproveCount?: number
+  pendingLeaderApproveCount?: number
+  monthBatchCount?: number
+  monthPeopleCount?: number
+  monthAmount?: number | string
+  townQualify?: Array<{
+    townId?: string
+    peopleCount?: number
+    villageCount?: number
+    withLandCount?: number
+    withoutLandCount?: number
+    qualifiedCount?: number
+  }>
+  villageCharts?: Array<{ villageId?: string; peopleCount?: number; amount?: number | string }>
+  batchStatusCounts?: Record<string, number>
+  recentAlerts?: Record<string, unknown>[]
+  monthBatches?: Record<string, unknown>[]
+}
+
+/** 线上旧包无 /workbench 时,用已有 summary + region-stats 拼装 */
+async function fetchWorkbenchLegacy(
+  payMonth: string,
+  townId?: string,
+  villageId?: string,
+  months = 6,
+): Promise<WorkbenchApi | null> {
+  const [summary, region] = await Promise.all([
+    get<WorkbenchApi['summary']>(
+      '/dashboard/summary',
+      { payMonth, townId: townId || undefined },
+      { silent: true },
+    ).catch(() => null),
+    get<RegionDashRow>(
+      '/dashboard/region-stats',
+      {
+        payMonth,
+        townId: townId || undefined,
+        villageId: villageId || undefined,
+        months,
+      },
+      { silent: true },
+    ).catch(() => null),
+  ])
+  if (!summary && !region) return null
+
+  const batchRes = await post<PageResult<Record<string, unknown>>>(
+    '/batches/page',
+    {
+      pageNum: 1,
+      pageSize: 80,
+      payMonth,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      batchLevel: villageId ? 'VILLAGE' : townId ? 'VILLAGE' : undefined,
+    },
+    { silent: true },
+  ).catch(() => null)
+  const monthBatches = batchRes?.list || []
+  const batchStatusCounts: Record<string, number> = {}
+  for (const b of monthBatches) {
+    const st = String(b.status || '')
+    if (!st) continue
+    batchStatusCounts[st] = (batchStatusCounts[st] || 0) + 1
+  }
+
+  const alertRes = await post<PageResult<Record<string, unknown>>>(
+    '/alerts/page',
+    { pageNum: 1, pageSize: 8, townId: townId || undefined, villageId: villageId || undefined },
+    { silent: true },
+  ).catch(() => null)
+
+  let villageCharts: WorkbenchApi['villageCharts']
+  if (townId && !villageId) {
+    const charts = await buildTownVillageChartsFromApi(townId, payMonth).catch(() => null)
+    if (charts) {
+      villageCharts = charts.people.map((p, idx) => ({
+        villageId: p.name,
+        peopleCount: p.value,
+        amount: charts.amount[idx]?.value || 0,
+      }))
+    }
+  }
+
+  return {
+    summary: summary || undefined,
+    region: region || undefined,
+    personnelCount: Number(region?.personnelCount || 0),
+    monthPeopleCount: Number(region?.currentMonth?.peopleCount || 0),
+    monthAmount: region?.currentMonth?.totalAmount,
+    monthBatchCount: monthBatches.length,
+    pendingTownApproveCount: Number(batchStatusCounts.PENDING_TOWN_APPROVE || 0),
+    pendingLeaderApproveCount: Number(batchStatusCounts.PENDING_LEADER_APPROVE || 0),
+    batchStatusCounts,
+    monthBatches,
+    recentAlerts: alertRes?.list || [],
+    villageCharts,
+  }
+}
+
+async function loadWorkbenchApi(
+  payMonth: string,
+  townId?: string,
+  villageId?: string,
+  months = 6,
+): Promise<WorkbenchApi | null> {
+  const wb = await get<WorkbenchApi>(
+    '/dashboard/workbench',
+    {
+      payMonth,
+      townId: townId || undefined,
+      villageId: villageId || undefined,
+      months,
+    },
+    { silent: true },
+  ).catch(() => null)
+  if (wb) return wb
+  return fetchWorkbenchLegacy(payMonth, townId, villageId, months)
+}
+
+/** —— 工作台:优先单接口聚合;旧后台无 workbench 时自动降级 —— */
 export async function fetchDashboard(params: {
   roleKey: string
   currentMonth: string
@@ -167,8 +351,7 @@ export async function fetchDashboard(params: {
   const { townId, villageId } = await resolveScopeIds(role.town, role.village)
   if (townId) await ensureVillages(townId)
   const payMonth = monthToPayMonth(params.currentMonth)
-  const base = buildDashboardByRole(role, params.currentMonth)
-  base.batchInfo.batchNo = payMonth
+  const base = emptyDashboard(role, payMonth)
   base.batchInfo.scope =
     role.village && role.town
       ? `${role.town}/${role.village}`
@@ -180,143 +363,212 @@ export async function fetchDashboard(params: {
             : '全区'
           : base.batchInfo.scope
 
-  try {
-    const [summary, region] = await Promise.all([
-      get<Record<string, unknown>>('/dashboard/summary', {
-        payMonth,
-        townId: townId || undefined,
-      }).catch(() => null),
-      get<RegionDashRow>('/dashboard/region-stats', {
-        payMonth,
-        townId: townId || undefined,
-        villageId: villageId || undefined,
-        months: 6,
-      }).catch(() => null),
-    ])
-
-    const halfYear = seriesToHalfYearCharts(region?.series, 6)
-    const cur = region?.currentMonth
-    let regionPeople = Number(cur?.peopleCount ?? 0)
-    let regionAmount = Number(cur?.totalAmount ?? 0)
-    const pendingAlert = Number(summary?.pendingAlertCount || 0)
-
-    // 批次有人数但金额未回填时,用本月村/镇批次再算一遍(避免金额柱图全 0)
-    if ((regionPeople > 0 && regionAmount <= 0) || (villageId && regionPeople <= 0)) {
-      try {
-        const monthBatches = await pageAll<Record<string, unknown>>((pageNum, pageSize) =>
-          post<PageResult<Record<string, unknown>>>('/batches/page', {
-            pageNum,
-            pageSize,
-            payMonth,
-            townId: townId || undefined,
-            villageId: villageId || undefined,
-            batchLevel: villageId ? 'VILLAGE' : undefined,
-          }),
-        )
-        let p = 0
-        let a = 0
-        for (const b of monthBatches) {
-          if (String(b.status || '') === 'ARCHIVED') continue
-          p += Number(b.peopleCount || 0)
-          a += Number(b.totalAmount || 0)
-        }
-        if (p > 0) regionPeople = p
-        if (a > 0) regionAmount = a
-      } catch {
-        /* ignore */
-      }
-    }
-    if (regionPeople <= 0) regionPeople = Number(summary?.totalPeople || 0)
-    if (regionAmount <= 0) regionAmount = Number(summary?.totalAmount || 0)
+  const wb = await loadWorkbenchApi(payMonth, townId || undefined, villageId || undefined, 6)
+
+  if (!wb) return base
+
+  const summary = wb.summary
+  const region = wb.region
+  const halfYear = seriesToHalfYearCharts(region?.series, 6)
+  let regionPeople = Number(region?.currentMonth?.peopleCount ?? wb.monthPeopleCount ?? 0)
+  let regionAmount = Number(region?.currentMonth?.totalAmount ?? wb.monthAmount ?? 0)
+  if (regionPeople <= 0) regionPeople = Number(summary?.totalPeople || 0)
+  if (regionAmount <= 0) regionAmount = Number(summary?.totalAmount || 0)
+  if (halfYear.people.length) {
+    const lastIdx = halfYear.people.length - 1
+    if (regionPeople > 0) halfYear.people[lastIdx].value = regionPeople
+    if (regionAmount > 0) halfYear.amount[lastIdx].value = regionAmount
+  }
 
-    // 把本月校正写回近半年序列最后一柱
-    if (halfYear.people.length) {
-      const lastIdx = halfYear.people.length - 1
-      if (regionPeople > 0) halfYear.people[lastIdx].value = regionPeople
-      if (regionAmount > 0) halfYear.amount[lastIdx].value = regionAmount
+  const pendingAlert = Number(summary?.pendingAlertCount || 0)
+  const pendingTown = Number(wb.pendingTownApproveCount || 0)
+  const pendingLeader = Number(wb.pendingLeaderApproveCount || 0)
+  const pending =
+    role.key === 'town' ? pendingTown : role.key === 'leader' ? pendingLeader : pendingTown + pendingLeader
+
+  base.stats.amount = regionAmount
+  base.stats.alert = pendingAlert
+  base.stats.normal = Number(region?.personnelCount ?? wb.personnelCount ?? regionPeople)
+  base.stats.pending = pending
+  base.batchInfo.peopleCount = regionPeople
+  base.batchInfo.amount = regionAmount
+  base.batchInfo.alertCount = pendingAlert
+  base.batchInfo.todoCount = role.allData ? 0 : pending
+
+  const monthBatches = (wb.monthBatches || []).map(mapVillageBatch)
+  const statusCounts = wb.batchStatusCounts || {}
+  base.statusPieMonth = Object.entries(statusCounts)
+    .map(([name, value]) => ({ name, value: Number(value) || 0 }))
+    .filter((i) => i.value > 0)
+  base.statusPieYear = base.statusPieMonth
+  base.recentAlerts = (wb.recentAlerts || []).map(mapAlert).slice(0, 8)
+  base.todos = role.allData
+    ? []
+    : monthBatches.slice(0, 12).map((b) => ({
+        type: '批次流转',
+        title: `${b.batchNo} ${b.town}${b.village}月度批次`,
+        node: b.stage,
+      }))
+
+  let workflowActive = FLOW_STAGES.length - 1
+  for (let i = 0; i < FLOW_STAGES.length; i++) {
+    if (monthBatches.some((b) => b.stage.includes(FLOW_STAGES[i]) || (i === 0 && b.stage === '退回修改'))) {
+      workflowActive = i
+      break
     }
+  }
+  base.workflowActive = workflowActive
+
+  if (role.village || role.key === 'village') {
+    base.villageHalfYearPeopleChart = halfYear.people
+    base.villageHalfYearAmountChart = halfYear.amount
+    base.townAmountChart = halfYear.amount
+    base.batchCompareChart = halfYear.amount.map((i, idx) => ({
+      ...i,
+      count: halfYear.people[idx]?.value,
+    }))
+  }
 
-    // 汇总卡片:优先区划统计(含村级),保证切换镇/村后数字一致
-    base.stats.amount = regionAmount
-    base.stats.alert = pendingAlert
-    base.stats.normal = Number(region?.personnelCount ?? regionPeople)
-    base.batchInfo.peopleCount = regionPeople
-    base.batchInfo.amount = regionAmount
-    base.batchInfo.alertCount = pendingAlert
-
-    // 村合作社:近半年图表用真实序列(不再用 mock 骨架)
-    if (role.village || role.key === 'village') {
-      base.villageHalfYearPeopleChart = halfYear.people
-      base.villageHalfYearAmountChart = halfYear.amount
-      base.townAmountChart = halfYear.amount
+  if (townId && !villageId && (role.key === 'town' || role.key === 'social' || role.key === 'leader')) {
+    const villageCharts = {
+      people: (wb.villageCharts || []).map((v) => ({
+        name: regionName(String(v.villageId || '')) || String(v.villageId || '未知村'),
+        value: Number(v.peopleCount || 0),
+      })),
+      amount: (wb.villageCharts || []).map((v) => ({
+        name: regionName(String(v.villageId || '')) || String(v.villageId || '未知村'),
+        value: Number(v.amount || 0),
+      })),
+    }
+    base.townVillagePeopleChart = villageCharts.people
+    base.townVillageAmountChart = villageCharts.amount
+    base.townAmountChart = villageCharts.amount
+    if (halfYear.people.length) {
       base.batchCompareChart = halfYear.amount.map((i, idx) => ({
         ...i,
         count: halfYear.people[idx]?.value,
       }))
     }
+  }
 
-    // 镇级:本批次各村柱状图
-    if (townId && !villageId && (role.key === 'town' || role.key === 'social' || role.key === 'leader')) {
-      try {
-        const villageCharts = await buildTownVillageChartsFromApi(townId, payMonth)
-        base.townVillagePeopleChart = villageCharts.people
-        base.townVillageAmountChart = villageCharts.amount
-        base.townAmountChart = villageCharts.amount
-      } catch {
-        /* 保留骨架 */
-      }
-      // 镇级也可看近半年趋势(右侧/其他图用到时)
-      if (halfYear.people.length) {
-        base.batchCompareChart = halfYear.amount.map((i, idx) => ({
-          ...i,
-          count: halfYear.people[idx]?.value,
-        }))
-      }
+  const withLand = Number(wb.withLandCount || 0)
+  const withoutLand = Number(wb.withoutLandCount || 0)
+  if (role.key === 'town') {
+    base.townReview = {
+      pendingCount: pendingTown,
+      withLandCount: withLand,
+      withoutLandCount: withoutLand,
+      pendingVillageCount: new Set(monthBatches.filter((b) => b.statusCode === 'PENDING_TOWN_APPROVE').map((b) => b.village)).size,
+      qualifyPie: [
+        { name: '有土地退养', value: withLand },
+        { name: '无土地有资格', value: withoutLand },
+      ].filter((i) => i.value > 0),
+      villageReviews: (wb.villageCharts || []).map((v) => ({
+        village: regionName(String(v.villageId || '')) || String(v.villageId || '未知村'),
+        total: Number(v.peopleCount || 0),
+        pending: 0,
+        withLand: 0,
+        withoutLand: 0,
+      })),
+      pendingPeople: [],
+      tips: TOWN_REVIEW_TIPS,
+    }
+  }
+
+  if (role.key === 'social') {
+    base.socialWorkbench = {
+      matchPendingCount: Number(statusCounts.PENDING_INSURANCE_MATCH || statusCounts.INSURANCE_MATCHING || 0),
+      compareReadyCount: Number(statusCounts.READY_EXPORT || 0),
+      diskPendingCount:
+        Number(statusCounts.READY_EXPORT || 0) + Number(statusCounts.EXPORTED || 0),
+      alertCount: pendingAlert,
+      tips: SOCIAL_WORKBENCH_TIPS,
     }
+  }
 
-    // 区级:近半年 + 各街镇(来自 summary.townStats)
-    if (role.allData && base.districtOverview) {
-      base.districtOverview.halfYearPeopleChart = halfYear.people
-      base.districtOverview.halfYearAmountChart = halfYear.amount
-      base.districtOverview.peopleCount = regionPeople
-      base.districtOverview.amount = regionAmount
-      const townStats = (summary?.townStats as BatchStatRow[] | undefined) || []
-      if (townStats.length) {
-        const byTown = new Map<string, { people: number; amount: number }>()
-        for (const s of townStats) {
-          const name = regionName(String(s.townId || '')) || String(s.townId || '未知')
-          const curT = byTown.get(name) || { people: 0, amount: 0 }
-          curT.people += Number(s.shouldPayCount || 0)
-          curT.amount += Number(s.shouldPayAmount || 0)
-          byTown.set(name, curT)
-        }
-        const names = Array.from(byTown.keys()).sort((a, b) => a.localeCompare(b, 'zh-CN'))
-        base.districtOverview.townCount = names.length
-        base.districtOverview.townPeopleChart = names.map((n) => ({
-          name: n,
-          value: byTown.get(n)?.people || 0,
-        }))
-        base.districtOverview.townAmountChart = names.map((n) => ({
-          name: n,
-          value: byTown.get(n)?.amount || 0,
-        }))
-        base.districtOverview.townSummary = names.map((n) => ({
-          town: n,
-          villageCount: 0,
-          peopleCount: byTown.get(n)?.people || 0,
-          amount: byTown.get(n)?.amount || 0,
-          withLandCount: 0,
-          withoutLandCount: 0,
-          qualifiedCount: byTown.get(n)?.people || 0,
-        }))
-        base.townAmountChart = base.districtOverview.townAmountChart
-      }
+  if (role.key === 'leader') {
+    base.leaderWorkbench = {
+      pendingApprovalCount: pendingLeader,
+      specialPendingCount: 0,
+      issueReadyCount: Number(statusCounts.READY_EXPORT || 0),
+      alertCount: pendingAlert,
+      tips: LEADER_WORKBENCH_TIPS,
     }
+  }
 
-    return base
-  } catch {
-    return base
+  if (role.allData && base.districtOverview) {
+    const townStats = (summary?.townStats as BatchStatRow[] | undefined) || []
+    const byTownPay = new Map<string, { people: number; amount: number }>()
+    for (const s of townStats) {
+      const name = regionName(String(s.townId || '')) || String(s.townId || '未知')
+      const curT = byTownPay.get(name) || { people: 0, amount: 0 }
+      curT.people += Number(s.shouldPayCount || 0)
+      curT.amount += Number(s.shouldPayAmount || 0)
+      byTownPay.set(name, curT)
+    }
+    const qRows = wb.townQualify || []
+    const names = Array.from(
+      new Set([
+        ...byTownPay.keys(),
+        ...qRows.map((r) => regionName(String(r.townId || '')) || String(r.townId || '未知')),
+      ]),
+    ).sort((a, b) => a.localeCompare(b, 'zh-CN'))
+    const qByName = new Map(
+      qRows.map((r) => [
+        regionName(String(r.townId || '')) || String(r.townId || '未知'),
+        r,
+      ]),
+    )
+
+    base.districtOverview.halfYearPeopleChart = halfYear.people
+    base.districtOverview.halfYearAmountChart = halfYear.amount
+    base.districtOverview.peopleCount = regionPeople
+    base.districtOverview.amount = regionAmount
+    base.districtOverview.withLandCount = withLand
+    base.districtOverview.withoutLandCount = withoutLand
+    base.districtOverview.qualifiedCount = Number(wb.qualifiedCount || 0)
+    base.districtOverview.townCount = names.length
+    base.districtOverview.villageCount = qRows.reduce((s, r) => s + Number(r.villageCount || 0), 0)
+    base.districtOverview.townPeopleChart = names.map((n) => ({
+      name: n,
+      value: byTownPay.get(n)?.people || Number(qByName.get(n)?.peopleCount || 0),
+    }))
+    base.districtOverview.townAmountChart = names.map((n) => ({
+      name: n,
+      value: byTownPay.get(n)?.amount || 0,
+    }))
+    base.districtOverview.townWithLandChart = names.map((n) => ({
+      name: n,
+      value: Number(qByName.get(n)?.withLandCount || 0),
+    }))
+    base.districtOverview.townWithoutLandChart = names.map((n) => ({
+      name: n,
+      value: Number(qByName.get(n)?.withoutLandCount || 0),
+    }))
+    base.districtOverview.qualifyPie = [
+      { name: '有土地退养', value: withLand },
+      { name: '无土地有资格', value: withoutLand },
+      {
+        name: '未标注资格',
+        value: Math.max(0, Number(wb.personnelCount || 0) - withLand - withoutLand),
+      },
+    ].filter((i) => i.value > 0)
+    base.districtOverview.townSummary = names.map((n) => {
+      const q = qByName.get(n)
+      return {
+        town: n,
+        villageCount: Number(q?.villageCount || 0),
+        peopleCount: byTownPay.get(n)?.people || Number(q?.peopleCount || 0),
+        amount: byTownPay.get(n)?.amount || 0,
+        withLandCount: Number(q?.withLandCount || 0),
+        withoutLandCount: Number(q?.withoutLandCount || 0),
+        qualifiedCount: Number(q?.qualifiedCount || 0),
+      }
+    })
+    base.townAmountChart = base.districtOverview.townAmountChart
   }
+
+  return base
 }
 
 /** —— 人员 / 村批 —— */
@@ -802,6 +1054,8 @@ export async function reviewPersonQualify(payload: {
 }) {
   const id = personIdStr(payload.personId)
   const detail = await get<Record<string, unknown>>(`/personnel/${encodeURIComponent(id)}`)
+  const code =
+    payload.retireQualifyType === '无土地有资格' ? 'WITHOUT_LAND' : 'WITH_LAND'
   const tip =
     payload.retireQualifyType === '无土地有资格'
       ? '无承包地,按政策认定具备退养补助资格;资格已核验'
@@ -818,6 +1072,7 @@ export async function reviewPersonQualify(payload: {
     monthlyStandard: detail?.monthlyStandard,
     enjoyStartMonth: detail?.enjoyStartMonth,
     source: detail?.source,
+    retireQualifyType: code,
     remarks: [detail?.remarks, tip].filter(Boolean).join(';'),
   })
   return { ok: true }
@@ -1093,19 +1348,17 @@ export async function createSpecialBiz(payload: {
   return { ok: true, id: String(created?.id || '') }
 }
 
-export async function exportSpecialSummary(type?: string) {
-  return {
-    ok: true,
-    fileName: type
-      ? `${currentPayMonth()}-${type}汇总文件.xlsx`
-      : '特殊业务汇总文件.xlsx',
-  }
+export async function exportSpecialSummary(_type?: string) {
+  return { ok: false as const, fileName: '', message: '特殊业务汇总导出未对接后端接口' }
 }
 
 export async function triggerAdjustReissue(town?: string) {
   const cfg = getAppConfig()
   const role = cfg.roles.find((r) => r.key === cfg.currentRole)
-  const townName = town || role?.town || '叶榭镇'
+  const townName = town || role?.town
+  if (!townName) {
+    return { ok: false, message: '请指定街镇', batchNo: currentPayMonth(), count: 0, villageCount: 0 }
+  }
   const { townId } = await resolveScopeIds(townName)
   if (!townId) return { ok: false, message: '未识别街镇', batchNo: currentPayMonth(), count: 0, villageCount: 0 }
   const created = await post<Record<string, unknown>>('/special-biz', {

+ 1 - 1
src/types/biz.ts

@@ -42,7 +42,7 @@ export interface TownPendingPersonItem {
   name: string
   idCard: string
   village: string
-  retireQualifyType: '有土地退养' | '无土地有资格'
+  retireQualifyType?: '有土地退养' | '无土地有资格'
   status: string
   source: string
   remark?: string

+ 104 - 128
src/utils/buildDashboard.ts

@@ -40,6 +40,95 @@ export const FLOW_STAGES = [
   '回盘信息确认',
 ]
 
+/** 各角色工作台操作/审批要点(文案固定,与数据源无关) */
+export const VILLAGE_WORKBENCH_TIPS = [
+  {
+    title: '人员新增录入',
+    desc: '仅限本村人员录入,完成信息填写后提交镇经发中心初审。',
+  },
+  {
+    title: '退回修改重提',
+    desc: '被退回修改的批次请核验人员信息后重新提交,勿跨村录入。',
+  },
+  {
+    title: '监控告警处理',
+    desc: '核实人员信息并可登记继承人,处理后与镇社区事务中心状态同步。',
+  },
+  {
+    title: '登记码填写',
+    desc: '社会保险登记码建议在录入时一并填写,便于后续居保状态匹配。',
+  },
+  {
+    title: '关键信息核对',
+    desc: '身份证号、银行卡号请仔细核对,避免回盘发放失败产生告警。',
+  },
+]
+
+export const TOWN_REVIEW_TIPS = [
+  {
+    title: '核验土地退养情况',
+    desc: '对照村级申报材料,确认新增人员是否确有土地退养事实,重点核对承包地、退养协议等要件。',
+  },
+  {
+    title: '无土地有资格情形',
+    desc: '对无承包地但按规定仍可享受退养补助的人员,核验政策依据与证明材料,备注说明资格来源。',
+  },
+  {
+    title: '按村逐户初审',
+    desc: '本镇各村新增人员统一纳入当前批次初审;材料不全或存疑可驳回村合作社补充修改后重提。',
+  },
+  {
+    title: '通过后流转',
+    desc: '初审通过后进入居保状态匹配;驳回后批次退回村合作社,勿跨村、跨批次混批处理。',
+  },
+]
+
+export const SOCIAL_WORKBENCH_TIPS = [
+  {
+    title: '居保状态匹配',
+    desc: '对本镇已初审通过批次触发居保交叉核验,产出比对结果清单;异常批次可退回镇经发中心。',
+  },
+  {
+    title: '特殊业务办理',
+    desc: '死亡录入、丧葬补贴、暂停/恢复及调标补发等特殊业务在本中心受理并流转审批。',
+  },
+  {
+    title: '提交分管领导',
+    desc: '比对结果确认无误后提交分管领导线上审批;材料不全或存疑勿提交。',
+  },
+  {
+    title: '出盘与回盘',
+    desc: '出盘即生成月补贴清单;确认回盘信息后完成发放闭环,失败记录需纳入下月补发。',
+  },
+  {
+    title: '监控告警处理',
+    desc: '及时处理居保匹配失败、回盘异常等告警,与村合作社、镇经发中心保持状态同步。',
+  },
+]
+
+export const LEADER_WORKBENCH_TIPS = [
+  {
+    title: '月补贴清单审批',
+    desc: '审阅镇社区事务受理服务中心提交的本镇月补贴批次;通过后进入出盘环节,由社区事务中心办理。',
+  },
+  {
+    title: '特殊业务审批',
+    desc: '审阅丧葬补贴、暂停/恢复、调标补发等特殊业务申请,材料不全或存疑应驳回补充。',
+  },
+  {
+    title: '驳回处理',
+    desc: '月度批次驳回后退回居保匹配;特殊业务驳回后退回业务受理补充/修改。',
+  },
+  {
+    title: '数据查询统计',
+    desc: '可按本镇各村查看批次与人员台账及金额类数据,便于审批前核对规模与异常。',
+  },
+  {
+    title: '审批边界',
+    desc: '您为社区事务中心上级领导,主要审批其提交的批次,不直接办理出盘、回盘等经办事项。',
+  },
+]
+
 /** 按角色过滤数据范围 */
 export function filterByRoleScope<T extends { town?: string; village?: string }>(
   list: T[],
@@ -259,24 +348,7 @@ function buildTownReviewData(
     qualifyPie,
     villageReviews,
     pendingPeople,
-    tips: [
-      {
-        title: '核验土地退养情况',
-        desc: '对照村级申报材料,确认新增人员是否确有土地退养事实,重点核对承包地、退养协议等要件。',
-      },
-      {
-        title: '无土地有资格情形',
-        desc: '对无承包地但按规定仍可享受退养补助的人员,核验政策依据与证明材料,备注说明资格来源。',
-      },
-      {
-        title: '按村逐户初审',
-        desc: '本镇各村新增人员统一纳入当前批次初审;材料不全或存疑可驳回村合作社补充修改后重提。',
-      },
-      {
-        title: '通过后流转',
-        desc: '初审通过后进入居保状态匹配;驳回后批次退回村合作社,勿跨村、跨批次混批处理。',
-      },
-    ],
+    tips: TOWN_REVIEW_TIPS,
   }
 }
 
@@ -422,8 +494,8 @@ function buildTodos(
   role: RoleItem,
   scopeApprovals: Approval[],
   scopeBatches: VillageBatch[],
-  batchNo: string,
-  scopeLabel: string,
+  _batchNo?: string,
+  _scopeLabel?: string,
 ): TodoItem[] {
   const fromApprovals = scopeApprovals
     .filter((a) => a.status === '待审批')
@@ -470,44 +542,16 @@ function buildTodos(
       batchNo: b.batchNo,
     }))
 
-  const merged = [...fromApprovals, ...fromBatches]
-  if (merged.length) {
-    return merged.map(({ type, title, node }) => ({ type, title, node }))
-  }
-  return [
-    {
-      type: '月补贴清单',
-      title: `${batchNo} ${scopeLabel}月补贴清单复核`,
-      node: '待复核',
-    },
-    {
-      type: '回盘确认',
-      title: `${batchNo} ${scopeLabel}回盘信息确认`,
-      node: '待回盘',
-    },
-  ]
+  return [...fromApprovals, ...fromBatches].map(({ type, title, node }) => ({
+    type,
+    title,
+    node,
+  }))
 }
 
-/** 近期告警(无数据时占位) */
-function buildRecentAlerts(
-  scopeAlerts: AlertItem[],
-  scopeLabel: string,
-): AlertItem[] {
-  if (scopeAlerts.length) return scopeAlerts.slice(0, 8)
-  return [
-    {
-      id: 'AL-MOCK-1',
-      batchNo: '-',
-      personName: '模拟人员',
-      town: '-',
-      village: '-',
-      type: '居保核验',
-      level: '中',
-      content: `${scopeLabel}本批次存在1条待人工复核记录`,
-      status: '待处理',
-      createdAt: '-',
-    },
-  ]
+/** 近期告警(无数据返回空,不造假) */
+function buildRecentAlerts(scopeAlerts: AlertItem[]): AlertItem[] {
+  return scopeAlerts.slice(0, 8)
 }
 
 /** 根据角色与发放周期构建工作台数据 */
@@ -595,28 +639,7 @@ export function buildDashboardByRole(
             (b.stage === '待线下发放' || b.stage === '回盘信息确认'),
         ).length,
         alertCount: batchAlertCount,
-        tips: [
-          {
-            title: '居保状态匹配',
-            desc: '对本镇已初审通过批次触发居保交叉核验,产出比对结果清单;异常批次可退回镇经发中心。',
-          },
-          {
-            title: '特殊业务办理',
-            desc: '死亡录入、丧葬补贴、暂停/恢复及调标补发等特殊业务在本中心受理并流转审批。',
-          },
-          {
-            title: '提交分管领导',
-            desc: '比对结果确认无误后提交分管领导线上审批;材料不全或存疑勿提交。',
-          },
-          {
-            title: '出盘与回盘',
-            desc: '出盘即生成月补贴清单;确认回盘信息后完成发放闭环,失败记录需纳入下月补发。',
-          },
-          {
-            title: '监控告警处理',
-            desc: '及时处理居保匹配失败、回盘异常等告警,与村合作社、镇经发中心保持状态同步。',
-          },
-        ],
+        tips: SOCIAL_WORKBENCH_TIPS,
       }
     : undefined
 
@@ -634,28 +657,7 @@ export function buildDashboardByRole(
             (b) => b.batchNo === batchNo && b.stage === '待线下发放',
           ).length,
           alertCount: batchAlertCount,
-          tips: [
-            {
-              title: '月补贴清单审批',
-              desc: '审阅镇社区事务受理服务中心提交的本镇月补贴批次;通过后进入出盘环节,由社区事务中心办理。',
-            },
-            {
-              title: '特殊业务审批',
-              desc: '审阅丧葬补贴、暂停/恢复、调标补发等特殊业务申请,材料不全或存疑应驳回补充。',
-            },
-            {
-              title: '驳回处理',
-              desc: '月度批次驳回后退回居保匹配;特殊业务驳回后退回业务受理补充/修改。',
-            },
-            {
-              title: '数据查询统计',
-              desc: '可按本镇各村查看批次与人员台账及金额类数据,便于审批前核对规模与异常。',
-            },
-            {
-              title: '审批边界',
-              desc: '您为社区事务中心上级领导,主要审批其提交的批次,不直接办理出盘、回盘等经办事项。',
-            },
-          ],
+          tips: LEADER_WORKBENCH_TIPS,
         }
       })()
     : undefined
@@ -664,30 +666,7 @@ export function buildDashboardByRole(
     ? buildDistrictOverview(role, scopePeople, batchPeople, currentMonth)
     : undefined
 
-  const villageTips = role.village
-    ? [
-        {
-          title: '人员新增录入',
-          desc: '仅限本村人员录入,完成信息填写后提交镇经发中心初审。',
-        },
-        {
-          title: '退回修改重提',
-          desc: '被退回修改的批次请核验人员信息后重新提交,勿跨村录入。',
-        },
-        {
-          title: '监控告警处理',
-          desc: '核实人员信息并可登记继承人,处理后与镇社区事务中心状态同步。',
-        },
-        {
-          title: '登记码填写',
-          desc: '社会保险登记码建议在录入时一并填写,便于后续居保状态匹配。',
-        },
-        {
-          title: '关键信息核对',
-          desc: '身份证号、银行卡号请仔细核对,避免回盘发放失败产生告警。',
-        },
-      ]
-    : undefined
+  const villageTips = role.village ? VILLAGE_WORKBENCH_TIPS : undefined
 
   /** 区农委 / 区人社局不展示流程待办 */
   const districtTodos = isDistrictPolicyRole(role) ? [] : todos
@@ -710,10 +689,7 @@ export function buildDashboardByRole(
     statusPieMonth: buildStatusPie(batchPeople),
     statusPieYear: buildStatusPie(scopePeople),
     todos: districtTodos,
-    recentAlerts: buildRecentAlerts(
-      scopeAlerts.filter((a) => a.status !== '已关闭'),
-      scopeLabel,
-    ),
+    recentAlerts: buildRecentAlerts(scopeAlerts.filter((a) => a.status !== '已关闭')),
     villageHalfYearPeopleChart: villageHalfYear?.people,
     villageHalfYearAmountChart: villageHalfYear?.amount,
     villageTips,

+ 47 - 8
src/utils/request.ts

@@ -15,12 +15,28 @@ export interface ApiResponse<T = unknown> {
   msg?: string
 }
 
+/** 扩展:silent 时网络/业务失败不弹 ElMessage(供可降级接口) */
+export type RequestConfig = AxiosRequestConfig & { silent?: boolean; skipCache?: boolean }
+
 const service: AxiosInstance = axios.create({
   // 联调默认走 Vite 代理 /api → sjnmtybt-server;亦可在 config.js 配 apiBase
   baseURL: getAppConfig().apiBase || '/api',
   timeout: getAppConfig().requestTimeout || 15000,
 })
 
+/** 短时 GET 缓存:工作台/区划等只读接口,避免切换角色重复打满 */
+const getCache = new Map<string, { expireAt: number; data: unknown }>()
+const GET_CACHE_TTL_MS = 8_000
+const GET_CACHE_PATHS = ['/dashboard/workbench', '/dashboard/summary', '/dashboard/region-stats', '/regions']
+
+function cacheKeyOf(url: string, params?: object) {
+  return url + '?' + JSON.stringify(params || {})
+}
+
+function shouldCacheGet(url: string) {
+  return GET_CACHE_PATHS.some((p) => url === p || url.startsWith(p + '?'))
+}
+
 // 请求拦截
 service.interceptors.request.use(
   (config: InternalAxiosRequestConfig) => {
@@ -56,18 +72,24 @@ service.interceptors.response.use(
     if (res.code === 0 || res.code === 200) {
       return response
     }
-    ElMessage.error(res.message || res.msg || '请求失败')
+    const silent = Boolean((response.config as RequestConfig | undefined)?.silent)
+    if (!silent) {
+      ElMessage.error(res.message || res.msg || '请求失败')
+    }
     return Promise.reject(new Error(res.message || res.msg || '请求失败'))
   },
   (error) => {
-    const msg = error.response?.data?.message || error.message || '网络异常'
-    ElMessage.error(msg)
+    const silent = Boolean((error.config as RequestConfig | undefined)?.silent)
+    if (!silent) {
+      const msg = error.response?.data?.message || error.message || '网络异常'
+      ElMessage.error(msg)
+    }
     return Promise.reject(error)
   },
 )
 
 /** 通用请求方法,返回业务 data */
-function request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+function request<T = unknown>(config: RequestConfig): Promise<T> {
   return service.request<ApiResponse<T>>(config).then((res) => {
     const data = res.data
     if (data && typeof data === 'object' && 'data' in data) {
@@ -77,19 +99,36 @@ function request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
   })
 }
 
-export function get<T = unknown>(url: string, params?: object, config?: AxiosRequestConfig) {
+export function get<T = unknown>(url: string, params?: object, config?: RequestConfig) {
+  if (shouldCacheGet(url) && !config?.skipCache) {
+    const key = cacheKeyOf(url, params)
+    const hit = getCache.get(key)
+    if (hit && hit.expireAt > Date.now()) {
+      return Promise.resolve(hit.data as T)
+    }
+    return request<T>({ ...config, url, method: 'GET', params }).then((data) => {
+      getCache.set(key, { expireAt: Date.now() + GET_CACHE_TTL_MS, data })
+      if (getCache.size > 80) {
+        const now = Date.now()
+        for (const [k, v] of getCache) {
+          if (v.expireAt <= now) getCache.delete(k)
+        }
+      }
+      return data
+    })
+  }
   return request<T>({ ...config, url, method: 'GET', params })
 }
 
-export function post<T = unknown>(url: string, data?: object, config?: AxiosRequestConfig) {
+export function post<T = unknown>(url: string, data?: object, config?: RequestConfig) {
   return request<T>({ ...config, url, method: 'POST', data })
 }
 
-export function put<T = unknown>(url: string, data?: object, config?: AxiosRequestConfig) {
+export function put<T = unknown>(url: string, data?: object, config?: RequestConfig) {
   return request<T>({ ...config, url, method: 'PUT', data })
 }
 
-export function del<T = unknown>(url: string, params?: object, config?: AxiosRequestConfig) {
+export function del<T = unknown>(url: string, params?: object, config?: RequestConfig) {
   return request<T>({ ...config, url, method: 'DELETE', params })
 }
 

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

@@ -376,7 +376,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, watch } from 'vue'
+import { computed, onMounted, ref, shallowRef, watch } from 'vue'
 import type { EChartsCoreOption } from 'echarts/core'
 // import StatCards from '@/components/common/StatCards.vue'
 import PagePanel from '@/components/common/PagePanel.vue'
@@ -393,8 +393,11 @@ const CHART_COLORS = ['#c9a66e', '#8b9d7a', '#d4a06a', '#b08a6a', '#9a8b7a']
 const CHART_COLOR_GREEN = '#8b9d7a'
 
 const userStore = useUserStore()
-const data = ref<DashboardData | null>(null)
+/** shallowRef:整包替换工作台数据,避免深层响应拖慢图表重算 */
+const data = shallowRef<DashboardData | null>(null)
 const loading = ref(false)
+let loadSeq = 0
+let loadTimer: ReturnType<typeof setTimeout> | null = null
 
 /** 是否村合作社视角 */
 const isVillage = computed(() => Boolean(userStore.roleInfo?.village))
@@ -980,26 +983,37 @@ function onMonthChange(month: string | null) {
   userStore.setMonth(month)
 }
 
-/** 按当前角色与发放周期加载工作台 */
+/** 按当前角色与发放周期加载工作台(防抖 + 竞态序号) */
 async function loadData() {
+  const seq = ++loadSeq
   loading.value = true
   try {
-    data.value = await fetchDashboard({
+    const next = await fetchDashboard({
       roleKey: userStore.currentRole,
       currentMonth: userStore.currentMonth,
     })
+    if (seq !== loadSeq) return
+    data.value = next
   } finally {
-    loading.value = false
+    if (seq === loadSeq) loading.value = false
   }
 }
 
+function scheduleLoad() {
+  if (loadTimer) clearTimeout(loadTimer)
+  loadTimer = setTimeout(() => {
+    loadTimer = null
+    void loadData()
+  }, 80)
+}
+
 onMounted(loadData)
 
-// 切换模拟角色 / 发放周期时刷新统计
+// 切换角色 / 发放周期时刷新(短防抖,避免连点)
 watch(
   () => [userStore.currentRole, userStore.currentMonth],
   () => {
-    loadData()
+    scheduleLoad()
   },
 )
 </script>

+ 5 - 5
src/views/system/SystemAdminView.vue

@@ -88,11 +88,11 @@ const {
   resetPage: resetLogPage,
 } = useClientPagination(logs)
 
-/** 全局业务概览(演示数字) */
+/** 全局业务概览(无数据为 0,不造假) */
 const overviewItems = computed(() => [
-  { label: '系统账号', value: accounts.value.length || 4, unit: '个' },
-  { label: '启用账号', value: accounts.value.filter((a) => a.status === '启用').length || 4, unit: '个' },
-  { label: '今日操作', value: logs.value.length || 2, unit: '次' },
+  { label: '系统账号', value: accounts.value.length, unit: '个' },
+  { label: '启用账号', value: accounts.value.filter((a) => a.status === '启用').length, unit: '个' },
+  { label: '今日操作', value: logs.value.length, unit: '次' },
   { label: '角色类型', value: userStore.roles.length, unit: '种' },
 ])
 
@@ -102,7 +102,7 @@ function roleLabel(roleKey: string) {
 }
 
 function onEditAccount() {
-  ElMessage.info('演示:待对接接口')
+  ElMessage.info('账号编辑接口尚未对接')
 }
 
 async function loadAccounts() {