DashboardView.vue 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875
  1. <template>
  2. <div
  3. class="dashboard-view page-stack"
  4. :class="{
  5. 'dashboard-view--village': isVillage || isTown || isSocial || isLeader,
  6. 'dashboard-view--district': isDistrictPolicy,
  7. }"
  8. v-loading="loading"
  9. >
  10. <!-- 核心指标:在册总人数 ≠ 本月发放人数 -->
  11. <StatCards :items="statItems" />
  12. <p class="dashboard-stat-note">
  13. <strong>在册总人数</strong>是当前角色范围内的人员档案底数(含暂停、死亡等仍在库人员),
  14. <strong>不是</strong>本月实际发放人数。
  15. 本月发放人数与金额按当前发放周期的月补清单口径统计,见下方对照表。
  16. </p>
  17. <!-- 5.1.1.1 近期告警滚动;点击跳转告警页 -->
  18. <section class="alert-ticker" aria-label="近期告警">
  19. <div class="alert-ticker__label">
  20. <el-icon><Bell /></el-icon>
  21. <span>近期告警</span>
  22. <em v-if="alertTickerItems.length">未关闭 {{ alertTickerItems.length }}</em>
  23. </div>
  24. <div class="alert-ticker__track-wrap" @mouseenter="tickerPaused = true" @mouseleave="tickerPaused = false">
  25. <div
  26. v-if="alertTickerItems.length"
  27. class="alert-ticker__track"
  28. :class="{ 'is-paused': tickerPaused }"
  29. :style="{ animationDuration: `${Math.max(12, alertTickerItems.length * 4)}s` }"
  30. >
  31. <button
  32. v-for="(a, idx) in alertTickerLoop"
  33. :key="`${a.id}-${idx}`"
  34. type="button"
  35. class="alert-ticker__item"
  36. @click="goAlert(a.id)"
  37. >
  38. <StatusTag :status="a.level" mode="level" />
  39. <span class="alert-ticker__type">{{ a.type || '告警' }}</span>
  40. <span class="alert-ticker__text">
  41. {{ a.personName ? `${a.personName}:` : '' }}{{ a.content || '—' }}
  42. </span>
  43. </button>
  44. </div>
  45. <div v-else class="alert-ticker__empty">暂无未关闭告警</div>
  46. </div>
  47. <el-button link type="primary" size="small" class="alert-ticker__more" @click="goAlert()">
  48. 全部告警
  49. </el-button>
  50. </section>
  51. <!-- 本批次发放概览 -->
  52. <section class="batch-overview">
  53. <div class="batch-overview__head">
  54. <div class="batch-overview__title">
  55. <span class="batch-overview__bar" />
  56. <div>
  57. <h3>{{ overviewTitle }}</h3>
  58. <p>
  59. <template v-if="isVillage || isTown">
  60. 统计范围
  61. <em>{{ data?.batchInfo.scope || '-' }}</em>
  62. · {{ roleTip }}
  63. </template>
  64. <template v-else>
  65. 全区月批 {{ data?.batchInfo.batchNo || '-' }} · 统计范围
  66. <em>{{ data?.batchInfo.scope || '-' }}</em>
  67. · {{ roleTip }}
  68. </template>
  69. </p>
  70. </div>
  71. </div>
  72. <div class="batch-overview__period">
  73. <span class="batch-overview__period-label">发放周期</span>
  74. <el-date-picker
  75. :model-value="userStore.currentMonth"
  76. type="month"
  77. value-format="YYYY-MM"
  78. format="YYYY-MM"
  79. size="small"
  80. placeholder="选择月份"
  81. :clearable="false"
  82. class="batch-overview__period-picker"
  83. @update:model-value="onMonthChange"
  84. />
  85. </div>
  86. </div>
  87. <div class="batch-overview__metrics">
  88. <div
  89. v-for="item in batchMetrics"
  90. :key="item.label"
  91. class="metric-item"
  92. :class="item.tone"
  93. >
  94. <div class="metric-item__icon">
  95. <el-icon><component :is="item.icon" /></el-icon>
  96. </div>
  97. <div class="metric-item__body">
  98. <div class="metric-item__label">{{ item.label }}</div>
  99. <div class="metric-item__value">
  100. {{ item.value }}
  101. <em v-if="item.unit">{{ item.unit }}</em>
  102. </div>
  103. </div>
  104. </div>
  105. </div>
  106. </section>
  107. <!-- 待办单独一行,放在状态占比/发放表之上 -->
  108. <PagePanel
  109. v-if="(data?.todos?.length ?? 0) > 0"
  110. title="待办事项"
  111. class="dashboard-todos-panel"
  112. >
  113. <template #extra>
  114. <span class="dashboard-todos-panel__count">
  115. 未读 {{ unreadTodoCount }} / 共 {{ data?.todos?.length ?? 0 }}
  116. </span>
  117. </template>
  118. <el-table
  119. :data="pagedTodos"
  120. border
  121. stripe
  122. size="small"
  123. class="dashboard-todos-table"
  124. @row-click="(row) => onTodoClick(row as TodoItem)"
  125. >
  126. <el-table-column prop="type" label="类型" width="120" show-overflow-tooltip />
  127. <el-table-column prop="title" label="事项" min-width="220" show-overflow-tooltip>
  128. <template #default="{ row }">
  129. <span :class="{ 'is-todo-read': (row as TodoItem).read }">{{ (row as TodoItem).title }}</span>
  130. </template>
  131. </el-table-column>
  132. <el-table-column prop="node" label="当前节点" width="150" show-overflow-tooltip />
  133. <el-table-column label="状态" width="80" align="center">
  134. <template #default="{ row }">
  135. <el-tag size="small" :type="(row as TodoItem).read ? 'info' : 'warning'" effect="plain">
  136. {{ (row as TodoItem).read ? '已读' : '未读' }}
  137. </el-tag>
  138. </template>
  139. </el-table-column>
  140. <el-table-column label="操作" width="100" align="center">
  141. <template #default="{ row }">
  142. <el-button
  143. v-if="canHandleTodo(row as TodoItem)"
  144. link
  145. type="primary"
  146. size="small"
  147. @click.stop="onTodoClick(row as TodoItem)"
  148. >
  149. 办理
  150. </el-button>
  151. <span v-else class="text-muted">—</span>
  152. </template>
  153. </el-table-column>
  154. </el-table>
  155. <TablePagination
  156. v-if="todoTotal > todoPageSize"
  157. v-model:page="todoPage"
  158. v-model:page-size="todoPageSize"
  159. :total="todoTotal"
  160. :page-sizes="TODO_PAGE_SIZES"
  161. />
  162. </PagePanel>
  163. <!-- 村 / 镇:发放表与状态饼图并排;待办已在上方单独一行 -->
  164. <div v-if="isTownLevelBoard" class="village-workspace">
  165. <div class="village-workspace__main" :class="{ 'has-pie': sidePieItems.length > 0 }">
  166. <PieMixPanel
  167. v-if="sidePieItems.length > 0"
  168. class="village-workspace__pie"
  169. :title="sidePieTitle"
  170. :items="sidePieItems"
  171. />
  172. <PagePanel :title="payTableTitle" class="village-workspace__table-panel village-workspace__pay">
  173. <el-table
  174. v-loading="payTableLoading"
  175. :data="payTableRows"
  176. border
  177. stripe
  178. size="small"
  179. max-height="300"
  180. >
  181. <el-table-column prop="name" :label="payTableNameLabel" min-width="110" />
  182. <el-table-column label="发放人数" width="100" align="right">
  183. <template #default="{ row }">{{ formatHeadcount(row.people) }}</template>
  184. </el-table-column>
  185. <el-table-column label="发放金额(元)" min-width="120" align="right">
  186. <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
  187. </el-table-column>
  188. </el-table>
  189. </PagePanel>
  190. </div>
  191. <div class="village-workspace__trend">
  192. <PagePanel title="月度发放人数与金额">
  193. <div class="village-workspace__chart-host">
  194. <ChartBox :option="townMonthPayCombineOption" height="100%" />
  195. </div>
  196. </PagePanel>
  197. <PagePanel title="年度发放人数与金额">
  198. <div class="village-workspace__chart-host">
  199. <ChartBox :option="townYearPayCombineOption" height="100%" />
  200. </div>
  201. </PagePanel>
  202. </div>
  203. <WorkbenchTipsPanel
  204. :title="workspaceTipsTitle"
  205. :tips="workspaceTips"
  206. :reset-key="tipsResetKey"
  207. />
  208. </div>
  209. <!-- 区农委 / 区人社局:全区各镇各村监测(无流程待办,图表丰富) -->
  210. <div v-else-if="isDistrictPolicy" class="district-workspace">
  211. <div class="district-workspace__charts">
  212. <!-- 农委侧重资格;人社侧重金额;共用人数图 -->
  213. <PagePanel :title="isAgri ? '各街镇有土地退养人数' : '各街镇发放人数与金额'">
  214. <div class="district-workspace__chart-host">
  215. <ChartBox
  216. :option="isAgri ? districtWithLandOption : districtTownPayCombineOption"
  217. height="100%"
  218. />
  219. </div>
  220. </PagePanel>
  221. <PagePanel :title="isAgri ? '各街镇无土地有资格人数' : '近半年发放人数与金额'">
  222. <div class="district-workspace__chart-host">
  223. <ChartBox
  224. :option="isAgri ? districtWithoutLandOption : districtHalfYearPayCombineOption"
  225. height="100%"
  226. />
  227. </div>
  228. </PagePanel>
  229. <PagePanel :title="isAgri ? '本月退养资格结构' : '近半年发放金额'">
  230. <div class="district-workspace__chart-host">
  231. <ChartBox
  232. :option="isAgri ? districtQualifyPieOption : districtHalfYearAmountOption"
  233. height="100%"
  234. />
  235. </div>
  236. </PagePanel>
  237. <PagePanel :title="isAgri ? '各街镇补助人数' : '近半年各批次发放人数'">
  238. <div class="district-workspace__chart-host">
  239. <ChartBox
  240. :option="isAgri ? districtTownPeopleOption : districtHalfYearPeopleOption"
  241. height="100%"
  242. />
  243. </div>
  244. </PagePanel>
  245. <PagePanel title="本月业务状态占比">
  246. <div class="district-workspace__chart-host">
  247. <ChartBox :option="statusPieMonthOption" height="100%" />
  248. </div>
  249. </PagePanel>
  250. <PagePanel :title="isAgri ? '近半年发放人数' : '本年度业务状态占比'">
  251. <div class="district-workspace__chart-host">
  252. <ChartBox
  253. :option="isAgri ? districtHalfYearPeopleOption : statusPieYearOption"
  254. height="100%"
  255. />
  256. </div>
  257. </PagePanel>
  258. </div>
  259. <PagePanel title="全区各街镇土地退养概况" class="district-overview-panel">
  260. <el-table
  261. class="district-overview-table"
  262. :data="data?.districtOverview?.townSummary ?? []"
  263. border
  264. stripe
  265. size="small"
  266. table-layout="fixed"
  267. >
  268. <el-table-column prop="town" label="街镇" min-width="100" align="center" />
  269. <el-table-column
  270. prop="villageCount"
  271. label="涉及村数"
  272. min-width="88"
  273. align="center"
  274. header-align="center"
  275. />
  276. <el-table-column
  277. prop="peopleCount"
  278. label="发放人数"
  279. min-width="88"
  280. align="center"
  281. header-align="center"
  282. />
  283. <el-table-column
  284. label="发放金额(元)"
  285. min-width="120"
  286. align="right"
  287. header-align="center"
  288. >
  289. <template #default="{ row }">{{ formatAmount(row.amount) }}</template>
  290. </el-table-column>
  291. <el-table-column
  292. label="人均(元)"
  293. min-width="88"
  294. align="right"
  295. header-align="center"
  296. >
  297. <template #default="{ row }">{{ formatPerCapita(row.amount, row.peopleCount) }}</template>
  298. </el-table-column>
  299. <el-table-column
  300. prop="withLandCount"
  301. label="有土地退养"
  302. min-width="100"
  303. align="center"
  304. header-align="center"
  305. />
  306. <el-table-column
  307. prop="withoutLandCount"
  308. label="无土地有资格"
  309. min-width="110"
  310. align="center"
  311. header-align="center"
  312. />
  313. <el-table-column
  314. v-if="isAgri"
  315. prop="qualifiedCount"
  316. label="资格已核验/标注"
  317. min-width="120"
  318. align="center"
  319. header-align="center"
  320. />
  321. </el-table>
  322. </PagePanel>
  323. <WorkbenchTipsPanel
  324. :title="districtTipsTitle"
  325. :tips="districtTips"
  326. :reset-key="tipsResetKey"
  327. />
  328. </div>
  329. <!-- 其他角色:图表 + 待办告警 -->
  330. <template v-else>
  331. <div class="dashboard-view__charts">
  332. <PagePanel :title="data?.barChartTitle || '发放金额统计'">
  333. <ChartBox :option="roleBarOption" height="300px" />
  334. </PagePanel>
  335. <PagePanel title="近半年发放金额对比">
  336. <ChartBox :option="batchCompareOption" height="300px" />
  337. </PagePanel>
  338. <PagePanel title="本月业务状态占比">
  339. <ChartBox :option="statusPieMonthOption" height="300px" />
  340. </PagePanel>
  341. <PagePanel title="本年度业务状态占比">
  342. <ChartBox :option="statusPieYearOption" height="300px" />
  343. </PagePanel>
  344. </div>
  345. <div class="dashboard-view__tables">
  346. <PagePanel title="近期告警">
  347. <template #extra>
  348. <span class="dashboard-todos-panel__count">共 {{ alertTotal }} 条</span>
  349. </template>
  350. <el-table :data="pagedAlerts" border stripe size="small">
  351. <el-table-column prop="level" label="等级" width="72" align="center">
  352. <template #default="{ row }">
  353. <StatusTag :status="row.level" mode="level" />
  354. </template>
  355. </el-table-column>
  356. <el-table-column prop="personName" label="人员" width="90" />
  357. <el-table-column prop="type" label="类型" width="110" show-overflow-tooltip />
  358. <el-table-column prop="content" label="内容" min-width="180" show-overflow-tooltip />
  359. </el-table>
  360. <TablePagination
  361. v-if="alertTotal > alertPageSize"
  362. v-model:page="alertPage"
  363. v-model:page-size="alertPageSize"
  364. :total="alertTotal"
  365. :page-sizes="TODO_PAGE_SIZES"
  366. />
  367. </PagePanel>
  368. </div>
  369. </template>
  370. </div>
  371. </template>
  372. <script setup lang="ts">
  373. import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
  374. import { useRouter } from 'vue-router'
  375. import { Bell } from '@element-plus/icons-vue'
  376. import type { EChartsCoreOption } from 'echarts/core'
  377. import StatCards from '@/components/common/StatCards.vue'
  378. import PagePanel from '@/components/common/PagePanel.vue'
  379. import TablePagination from '@/components/common/TablePagination.vue'
  380. import ChartBox from '@/components/common/ChartBox.vue'
  381. import PieMixPanel from '@/components/common/PieMixPanel.vue'
  382. import WorkbenchTipsPanel from '@/components/common/WorkbenchTipsPanel.vue'
  383. import { useClientPagination } from '@/composables/useClientPagination'
  384. import StatusTag from '@/components/common/StatusTag.vue'
  385. import { fetchDashboard, fetchTownVillagePayTable, markWorkbenchTodoRead } from '@/api/biz'
  386. import { useUserStore } from '@/stores/user'
  387. import { formatAmount } from '@/utils/mask'
  388. import {
  389. ADMIN_WORKBENCH_TIPS,
  390. AGRI_WORKBENCH_TIPS,
  391. HR_WORKBENCH_TIPS,
  392. } from '@/utils/buildDashboard'
  393. import type { AlertItem, ChartBarItem, DashboardData, TodoItem } from '@/types/biz'
  394. const router = useRouter()
  395. const tickerPaused = ref(false)
  396. let refreshTimer: ReturnType<typeof setInterval> | null = null
  397. /** 指标/告警自动刷新间隔(可研:实时刷新) */
  398. const DASHBOARD_REFRESH_MS = 120_000
  399. /** 图表主色:政务蓝行政台 */
  400. const CHART_COLOR = '#2B73F8'
  401. const CHART_COLORS = ['#2B73F8', '#27C98E', '#FA8C16', '#722ED1', '#1890FF']
  402. const CHART_COLOR_GREEN = '#27C98E'
  403. const userStore = useUserStore()
  404. /** shallowRef:整包替换工作台数据,避免深层响应拖慢图表重算 */
  405. const data = shallowRef<DashboardData | null>(null)
  406. const loading = ref(false)
  407. const payTableLoading = ref(false)
  408. let loadSeq = 0
  409. let loadTimer: ReturnType<typeof setTimeout> | null = null
  410. /** 是否村合作社视角 */
  411. const isVillage = computed(() => Boolean(userStore.roleInfo?.village))
  412. /** 是否镇经发中心视角 */
  413. const isTown = computed(() => userStore.roleInfo?.key === 'town')
  414. /** 是否镇社区事务受理中心视角 */
  415. const isSocial = computed(() => userStore.roleInfo?.key === 'social')
  416. /** 是否镇社区受理中心分管领导视角 */
  417. const isLeader = computed(() => userStore.roleInfo?.key === 'leader')
  418. /** 是否区农委 */
  419. const isAgri = computed(() => userStore.roleInfo?.key === 'agri')
  420. /** 是否区人社局 */
  421. const isHr = computed(() => userStore.roleInfo?.key === 'hr')
  422. /** 是否系统管理员 */
  423. const isAdmin = computed(() => userStore.roleInfo?.key === 'admin')
  424. /** 区级政策监测(农委 / 人社 / 系统管理员) */
  425. const isDistrictPolicy = computed(() => isAgri.value || isHr.value || isAdmin.value)
  426. /** 村 / 镇四级:发放表 + 饼图共用同一套栅格 */
  427. const isTownLevelBoard = computed(
  428. () => isVillage.value || isTown.value || isSocial.value || isLeader.value,
  429. )
  430. const payTableTitle = computed(() => {
  431. if (isVillage.value) return '本村近半年发放人数与金额'
  432. if (isTown.value) return '本镇各村发放人数与金额'
  433. return '本月本镇各村发放人数与金额'
  434. })
  435. const payTableNameLabel = computed(() => (isVillage.value ? '发放周期' : '村居'))
  436. const payTableRows = computed(() =>
  437. isVillage.value ? villagePayRows.value : townVillagePayRows.value,
  438. )
  439. const sidePieTitle = computed(() => (isTown.value ? '本月退养资格结构' : '本月业务状态占比'))
  440. const sidePieItems = computed(() =>
  441. isTown.value ? townQualifyPieItems.value : (data.value?.statusPieMonth ?? []),
  442. )
  443. const workspaceTipsTitle = computed(() => {
  444. if (isVillage.value) return '村级业务操作提示'
  445. if (isTown.value) return '资格审批要点'
  446. if (isSocial.value) return '业务办理要点'
  447. if (isLeader.value) return '审批要点'
  448. return '操作提示'
  449. })
  450. const workspaceTips = computed(() => {
  451. if (isVillage.value) return data.value?.villageTips ?? []
  452. if (isTown.value) return data.value?.townReview?.tips ?? []
  453. if (isSocial.value) return data.value?.socialWorkbench?.tips ?? []
  454. if (isLeader.value) return data.value?.leaderWorkbench?.tips ?? []
  455. return []
  456. })
  457. /** 概览标题 */
  458. const overviewTitle = computed(() => {
  459. if (isVillage.value) return '本村人员与资格'
  460. if (isTown.value) return '本镇退养资格审批'
  461. if (isSocial.value) return '本镇居保匹配与发放办理'
  462. if (isLeader.value) return '本镇月度清单线上审批'
  463. if (isAgri.value) return '全区土地退养资格监测'
  464. if (isHr.value) return '全区土地退养发放监测'
  465. if (isAdmin.value) return '全区业务数据监测'
  466. return '本月发放统计'
  467. })
  468. /** 角色提示文案 */
  469. const roleTip = computed(() => {
  470. const role = userStore.roleInfo
  471. if (!role) return ''
  472. if (role.key === 'admin') return '运维全区月批与基础数据,不替代各镇业务审批'
  473. if (role.key === 'agri') return '全区土地退养资格只读监测'
  474. if (role.key === 'hr') return '全区发放与居保只读监测(告警不可关闭)'
  475. if (role.allData) return '全区数据视角'
  476. if (role.village) return '本村人员录入与提交资格'
  477. if (role.key === 'town') return '本镇按人审退养资格'
  478. if (role.key === 'social') return '本镇居保、清单、特殊业务与出盘回盘'
  479. if (role.key === 'leader') return '审本镇月度清单与特殊业务(镇社区受理中心分管领导)'
  480. if (role.town) return '本镇数据视角'
  481. return role.label
  482. })
  483. const districtTips = computed(() => {
  484. if (isAgri.value) return AGRI_WORKBENCH_TIPS
  485. if (isHr.value) return HR_WORKBENCH_TIPS
  486. if (isAdmin.value) return ADMIN_WORKBENCH_TIPS
  487. return []
  488. })
  489. const districtTipsTitle = computed(() => {
  490. if (isAgri.value) return '政策监测要点'
  491. if (isHr.value) return '发放监测要点'
  492. if (isAdmin.value) return '运维要点'
  493. return '工作要点'
  494. })
  495. const tipsResetKey = computed(() => userStore.currentRole || '')
  496. const REGISTERED_COUNT_TIP =
  497. '当前角色范围内人员档案底数(含暂停、死亡等仍在库人员),不是本月月补清单发放人数。'
  498. const MONTH_PAY_COUNT_TIP = '当前发放周期纳入月补清单的人数,与在册总人数口径不同。'
  499. const MONTH_AMOUNT_TIP = '当前发放周期月补清单应发合计。'
  500. /** 核心指标:在册总人数与本月发放人数分列,避免混用 */
  501. const statItems = computed(() => {
  502. const s = data.value?.stats
  503. const payPeople = data.value?.batchInfo?.peopleCount
  504. if (!s) {
  505. return [
  506. { label: '在册总人数', value: '-', unit: '人', tip: REGISTERED_COUNT_TIP },
  507. { label: '本月发放人数', value: '-', unit: '人', tip: MONTH_PAY_COUNT_TIP },
  508. { label: '本月新增', value: '-', unit: '人', tip: '本月新纳入在册的人数。' },
  509. { label: '本月减少', value: '-', unit: '人', tip: '本月因暂停/停止/死亡等退出发放的人数。' },
  510. { label: '本月发放金额', value: '-', unit: '元', tip: MONTH_AMOUNT_TIP },
  511. ]
  512. }
  513. return [
  514. { label: '在册总人数', value: s.normal, unit: '人', tip: REGISTERED_COUNT_TIP },
  515. { label: '本月发放人数', value: payPeople ?? 0, unit: '人', tip: MONTH_PAY_COUNT_TIP },
  516. { label: '本月新增', value: s.monthIncrease ?? 0, unit: '人', tip: '本月新纳入在册的人数。' },
  517. { label: '本月减少', value: s.monthDecrease ?? 0, unit: '人', tip: '本月因暂停/停止/死亡等退出发放的人数。' },
  518. { label: '本月发放金额', value: formatAmount(s.amount), unit: '元', tip: MONTH_AMOUNT_TIP },
  519. ]
  520. })
  521. const alertTickerItems = computed(() => data.value?.recentAlerts ?? [])
  522. /** 双份拼接实现无缝横向滚动 */
  523. const alertTickerLoop = computed(() => {
  524. const list = alertTickerItems.value
  525. if (!list.length) return [] as AlertItem[]
  526. return list.length === 1 ? list : [...list, ...list]
  527. })
  528. function goAlert(alertId?: string) {
  529. if (alertId) {
  530. void router.push({ path: '/alerts', query: { id: alertId } })
  531. } else {
  532. void router.push('/alerts')
  533. }
  534. }
  535. /** 概览指标(按角色侧重) */
  536. const batchMetrics = computed(() => {
  537. const info = data.value?.batchInfo
  538. if (isVillage.value) {
  539. const s = data.value?.stats
  540. return [
  541. {
  542. label: '本村在册',
  543. value: s?.normal ?? info?.peopleCount ?? '-',
  544. unit: '人',
  545. icon: 'User',
  546. tone: 'tone-green',
  547. },
  548. {
  549. label: '本月新增',
  550. value: s?.monthIncrease ?? 0,
  551. unit: '人',
  552. icon: 'Checked',
  553. tone: 'tone-plain',
  554. },
  555. {
  556. label: '本月减少',
  557. value: s?.monthDecrease ?? 0,
  558. unit: '人',
  559. icon: 'Bell',
  560. tone: 'tone-gold',
  561. },
  562. {
  563. label: '预计发放',
  564. value: s ? formatAmount(s.amount) : info ? formatAmount(info.amount) : '-',
  565. unit: '元',
  566. icon: 'Wallet',
  567. tone: 'tone-gold',
  568. },
  569. ]
  570. }
  571. if (isTown.value) {
  572. const review = data.value?.townReview
  573. return [
  574. {
  575. label: '发放月份',
  576. value: userStore.currentMonth || '-',
  577. icon: 'Calendar',
  578. tone: 'tone-gold',
  579. },
  580. {
  581. label: '待资格审批',
  582. value: review?.pendingCount ?? 0,
  583. unit: '人',
  584. icon: 'User',
  585. tone: 'tone-green',
  586. },
  587. {
  588. label: '有土地退养',
  589. value: review?.withLandCount ?? 0,
  590. unit: '人',
  591. icon: 'OfficeBuilding',
  592. tone: 'tone-plain',
  593. },
  594. {
  595. label: '无土地有资格',
  596. value: review?.withoutLandCount ?? 0,
  597. unit: '人',
  598. icon: 'Wallet',
  599. tone: 'tone-gold',
  600. },
  601. ]
  602. }
  603. if (isSocial.value) {
  604. const sw = data.value?.socialWorkbench
  605. return [
  606. {
  607. label: '待居保匹配',
  608. value: sw?.matchPendingCount ?? 0,
  609. unit: '批',
  610. icon: 'Connection',
  611. tone: 'tone-green',
  612. },
  613. {
  614. label: '待提交领导',
  615. value: sw?.compareReadyCount ?? 0,
  616. unit: '批',
  617. icon: 'Checked',
  618. tone: 'tone-plain',
  619. },
  620. {
  621. label: '待回盘',
  622. value: sw?.diskPendingCount ?? 0,
  623. unit: '批',
  624. icon: 'Ticket',
  625. tone: 'tone-gold',
  626. },
  627. {
  628. label: '未关闭告警',
  629. value: sw?.alertCount ?? 0,
  630. unit: '条',
  631. icon: 'Bell',
  632. tone: 'tone-gold',
  633. },
  634. ]
  635. }
  636. if (isLeader.value) {
  637. const lw = data.value?.leaderWorkbench
  638. return [
  639. {
  640. label: '月补贴待审',
  641. value: lw?.pendingApprovalCount ?? 0,
  642. unit: '件',
  643. icon: 'Document',
  644. tone: 'tone-green',
  645. },
  646. {
  647. label: '特殊业务待审',
  648. value: lw?.specialPendingCount ?? 0,
  649. unit: '件',
  650. icon: 'Checked',
  651. tone: 'tone-plain',
  652. },
  653. {
  654. label: '待出盘',
  655. value: lw?.issueReadyCount ?? 0,
  656. unit: '批',
  657. icon: 'Ticket',
  658. tone: 'tone-gold',
  659. },
  660. {
  661. label: '未关闭告警',
  662. value: lw?.alertCount ?? 0,
  663. unit: '条',
  664. icon: 'Bell',
  665. tone: 'tone-gold',
  666. },
  667. ]
  668. }
  669. if (isAgri.value) {
  670. const d = data.value?.districtOverview
  671. return [
  672. {
  673. label: '全区月批',
  674. value: info?.batchNo ?? '-',
  675. icon: 'Ticket',
  676. tone: 'tone-gold',
  677. },
  678. {
  679. label: '覆盖街镇/村',
  680. value: `${d?.townCount ?? 0}/${d?.villageCount ?? 0}`,
  681. icon: 'OfficeBuilding',
  682. tone: 'tone-plain',
  683. },
  684. {
  685. label: '有土地退养',
  686. value: d?.withLandCount ?? 0,
  687. unit: '人',
  688. icon: 'User',
  689. tone: 'tone-green',
  690. },
  691. {
  692. label: '无土地有资格',
  693. value: d?.withoutLandCount ?? 0,
  694. unit: '人',
  695. icon: 'Checked',
  696. tone: 'tone-gold',
  697. },
  698. ]
  699. }
  700. if (isHr.value || isAdmin.value) {
  701. const d = data.value?.districtOverview
  702. return [
  703. {
  704. label: '全区月批',
  705. value: info?.batchNo ?? '-',
  706. icon: 'Ticket',
  707. tone: 'tone-gold',
  708. },
  709. {
  710. label: '覆盖街镇/村',
  711. value: `${d?.townCount ?? 0}/${d?.villageCount ?? 0}`,
  712. icon: 'OfficeBuilding',
  713. tone: 'tone-plain',
  714. },
  715. {
  716. label: '涉及人数',
  717. value: d?.peopleCount ?? 0,
  718. unit: '人',
  719. icon: 'User',
  720. tone: 'tone-green',
  721. },
  722. {
  723. label: '预计发放总额',
  724. value: d ? formatAmount(d.amount) : '-',
  725. unit: '元',
  726. icon: 'Wallet',
  727. tone: 'tone-gold',
  728. },
  729. ]
  730. }
  731. return [
  732. {
  733. label: '全区月批',
  734. value: info?.batchNo ?? '-',
  735. icon: 'Ticket',
  736. tone: 'tone-gold',
  737. },
  738. {
  739. label: '统计范围',
  740. value: info?.scope ?? '-',
  741. icon: 'OfficeBuilding',
  742. tone: 'tone-plain',
  743. },
  744. {
  745. label: '涉及人数',
  746. value: info?.peopleCount ?? '-',
  747. unit: '人',
  748. icon: 'User',
  749. tone: 'tone-green',
  750. },
  751. {
  752. label: '预计发放金额',
  753. value: info ? formatAmount(info.amount) : '-',
  754. unit: '元',
  755. icon: 'Wallet',
  756. tone: 'tone-gold',
  757. },
  758. ]
  759. })
  760. /** 通用柱状图配置 */
  761. function buildBarOption(
  762. list: ChartBarItem[],
  763. yName: string,
  764. valueFormatter?: (v: unknown) => string,
  765. barColorStops = [
  766. { offset: 0, color: '#4A8AFF' },
  767. { offset: 1, color: '#2B73F8' },
  768. ],
  769. ): EChartsCoreOption {
  770. const names = list.map((i) => i.name)
  771. const many = names.length > 6
  772. const endPercent = many ? Math.min(100, Math.round((6 / names.length) * 100)) : 100
  773. return {
  774. color: [CHART_COLOR],
  775. tooltip: {
  776. trigger: 'axis',
  777. backgroundColor: 'rgba(255,255,255,0.96)',
  778. borderColor: '#d6e4ff',
  779. textStyle: { color: '#333', fontSize: 12 },
  780. valueFormatter,
  781. },
  782. grid: { left: 56, right: 16, top: 28, bottom: many ? 80 : 36 },
  783. dataZoom: many
  784. ? [
  785. {
  786. type: 'inside',
  787. xAxisIndex: 0,
  788. zoomOnMouseWheel: true,
  789. moveOnMouseMove: true,
  790. },
  791. {
  792. type: 'slider',
  793. xAxisIndex: 0,
  794. height: 18,
  795. bottom: 6,
  796. start: 0,
  797. end: endPercent,
  798. showDetail: false,
  799. brushSelect: false,
  800. },
  801. ]
  802. : undefined,
  803. xAxis: {
  804. type: 'category',
  805. data: names,
  806. axisLine: { lineStyle: { color: '#e0d6c8' } },
  807. axisLabel: {
  808. color: '#8c8c8c',
  809. fontSize: many ? 10 : 12,
  810. interval: 0,
  811. rotate: many ? 40 : 0,
  812. hideOverlap: false,
  813. },
  814. axisTick: { show: false },
  815. },
  816. yAxis: {
  817. type: 'value',
  818. name: yName,
  819. nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
  820. axisLabel: { color: '#8c8c8c', fontSize: 11 },
  821. splitLine: { lineStyle: { color: '#f0ebe3', type: 'dashed' } },
  822. axisLine: { show: false },
  823. minInterval: yName.includes('人') ? 1 : undefined,
  824. },
  825. series: [
  826. {
  827. type: 'bar',
  828. barMaxWidth: 36,
  829. data: list.map((i) => i.value),
  830. itemStyle: {
  831. borderRadius: [2, 2, 0, 0],
  832. color: {
  833. type: 'linear',
  834. x: 0,
  835. y: 0,
  836. x2: 0,
  837. y2: 1,
  838. colorStops: barColorStops,
  839. },
  840. },
  841. },
  842. ],
  843. }
  844. }
  845. function formatHeadcount(n: number) {
  846. return Number(n || 0).toLocaleString('zh-CN')
  847. }
  848. function formatPerCapita(amount: number, people: number) {
  849. const p = Number(people || 0)
  850. if (!p) return '—'
  851. return formatAmount(Number(amount || 0) / p)
  852. }
  853. function mergePayRows(people: ChartBarItem[] = [], amounts: ChartBarItem[] = []) {
  854. const map = new Map<string, { name: string; people: number; amount: number }>()
  855. for (const p of people) {
  856. map.set(p.name, { name: p.name, people: Number(p.value || 0), amount: 0 })
  857. }
  858. for (const a of amounts) {
  859. const row = map.get(a.name) || { name: a.name, people: 0, amount: 0 }
  860. row.amount = Number(a.value || 0)
  861. map.set(a.name, row)
  862. }
  863. return [...map.values()]
  864. }
  865. function buildPayCombineOption(list: Array<{ name: string; people: number; amount: number }>): EChartsCoreOption {
  866. return {
  867. color: [CHART_COLOR, '#1A56C4'],
  868. tooltip: {
  869. trigger: 'axis',
  870. backgroundColor: 'rgba(255,255,255,0.96)',
  871. borderColor: '#d6e4ff',
  872. textStyle: { color: '#333', fontSize: 12 },
  873. },
  874. legend: {
  875. data: ['发放金额', '发放人数'],
  876. top: 0,
  877. left: 'center',
  878. orient: 'horizontal',
  879. itemGap: 18,
  880. itemWidth: 12,
  881. itemHeight: 8,
  882. textStyle: { color: '#595959', fontSize: 12 },
  883. },
  884. grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
  885. xAxis: {
  886. type: 'category',
  887. data: list.map((i) => i.name),
  888. axisLine: { lineStyle: { color: '#e0d6c8' } },
  889. axisLabel: { color: '#8c8c8c', fontSize: 11 },
  890. axisTick: { show: false },
  891. },
  892. yAxis: [
  893. {
  894. type: 'value',
  895. name: '金额(元)',
  896. nameGap: 14,
  897. nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
  898. axisLabel: {
  899. color: '#8c8c8c',
  900. fontSize: 11,
  901. formatter: (v: number) => (Math.abs(v) >= 10000 ? `${v / 10000}万` : String(v)),
  902. },
  903. splitLine: { lineStyle: { color: '#f0ebe3', type: 'dashed' } },
  904. axisLine: { show: false },
  905. },
  906. {
  907. type: 'value',
  908. name: '人数',
  909. nameGap: 14,
  910. nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
  911. axisLabel: { color: '#8c8c8c', fontSize: 11 },
  912. splitLine: { show: false },
  913. axisLine: { show: false },
  914. },
  915. ],
  916. series: [
  917. {
  918. name: '发放金额',
  919. type: 'bar',
  920. barMaxWidth: 28,
  921. itemStyle: {
  922. borderRadius: [2, 2, 0, 0],
  923. color: {
  924. type: 'linear',
  925. x: 0,
  926. y: 0,
  927. x2: 0,
  928. y2: 1,
  929. colorStops: [
  930. { offset: 0, color: '#4A8AFF' },
  931. { offset: 1, color: '#2B73F8' },
  932. ],
  933. },
  934. },
  935. data: list.map((i) => i.amount),
  936. },
  937. {
  938. name: '发放人数',
  939. type: 'line',
  940. yAxisIndex: 1,
  941. smooth: true,
  942. symbol: 'circle',
  943. symbolSize: 6,
  944. lineStyle: { width: 2, color: '#1A56C4' },
  945. itemStyle: { color: '#1A56C4', borderWidth: 2, borderColor: '#fff' },
  946. data: list.map((i) => i.people),
  947. },
  948. ],
  949. }
  950. }
  951. const villagePayRows = computed(() =>
  952. mergePayRows(data.value?.villageHalfYearPeopleChart, data.value?.villageHalfYearAmountChart),
  953. )
  954. const townVillagePayRows = computed(() =>
  955. mergePayRows(data.value?.townVillagePeopleChart, data.value?.townVillageAmountChart),
  956. )
  957. const townMonthPayRows = computed(() =>
  958. mergePayRows(data.value?.monthPeopleChart, data.value?.monthAmountChart),
  959. )
  960. const townYearPayRows = computed(() =>
  961. mergePayRows(data.value?.yearPeopleChart, data.value?.yearAmountChart),
  962. )
  963. const townMonthPayCombineOption = computed(() => buildPayCombineOption(townMonthPayRows.value))
  964. const townYearPayCombineOption = computed(() => buildPayCombineOption(townYearPayRows.value))
  965. const districtTownPayRows = computed(() =>
  966. mergePayRows(
  967. data.value?.districtOverview?.townPeopleChart,
  968. data.value?.districtOverview?.townAmountChart,
  969. ),
  970. )
  971. const districtHalfYearPayRows = computed(() =>
  972. mergePayRows(
  973. data.value?.districtOverview?.halfYearPeopleChart,
  974. data.value?.districtOverview?.halfYearAmountChart,
  975. ),
  976. )
  977. const districtTownPayCombineOption = computed(() => buildPayCombineOption(districtTownPayRows.value))
  978. const districtHalfYearPayCombineOption = computed(() =>
  979. buildPayCombineOption(districtHalfYearPayRows.value),
  980. )
  981. const townQualifyPieItems = computed(() => {
  982. const pie = data.value?.townReview?.qualifyPie
  983. if (pie?.length) return pie
  984. const r = data.value?.townReview
  985. return [
  986. { name: '有土地退养', value: r?.withLandCount ?? 0 },
  987. { name: '无土地有资格', value: r?.withoutLandCount ?? 0 },
  988. ].filter((i) => i.value > 0)
  989. })
  990. /** 角色维度柱状图(标题与聚合维度均随角色变化) */
  991. const roleBarOption = computed(() =>
  992. buildBarOption(
  993. data.value?.townAmountChart ?? [],
  994. '金额(元)',
  995. (v) => `¥${Number(v).toLocaleString('zh-CN')}`,
  996. ),
  997. )
  998. /** 不同批次发放金额对比(柱+折线) */
  999. const batchCompareOption = computed<EChartsCoreOption>(() => {
  1000. const list = data.value?.batchCompareChart ?? []
  1001. return {
  1002. color: [CHART_COLOR, '#1A56C4'],
  1003. tooltip: {
  1004. trigger: 'axis',
  1005. backgroundColor: 'rgba(255,255,255,0.96)',
  1006. borderColor: '#d6e4ff',
  1007. textStyle: { color: '#333', fontSize: 12 },
  1008. },
  1009. legend: {
  1010. data: ['发放金额', '发放人数'],
  1011. top: 0,
  1012. left: 'center',
  1013. orient: 'horizontal',
  1014. itemGap: 18,
  1015. itemWidth: 12,
  1016. itemHeight: 8,
  1017. textStyle: { color: '#595959', fontSize: 12 },
  1018. },
  1019. grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
  1020. xAxis: {
  1021. type: 'category',
  1022. data: list.map((i) => i.name),
  1023. axisLine: { lineStyle: { color: '#e0d6c8' } },
  1024. axisLabel: { color: '#8c8c8c', fontSize: 12 },
  1025. axisTick: { show: false },
  1026. },
  1027. yAxis: [
  1028. {
  1029. type: 'value',
  1030. name: '金额(元)',
  1031. nameGap: 14,
  1032. nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
  1033. axisLabel: {
  1034. color: '#8c8c8c',
  1035. fontSize: 11,
  1036. formatter: (v: number) => (Math.abs(v) >= 10000 ? `${v / 10000}万` : String(v)),
  1037. },
  1038. splitLine: { lineStyle: { color: '#f0ebe3', type: 'dashed' } },
  1039. axisLine: { show: false },
  1040. },
  1041. {
  1042. type: 'value',
  1043. name: '人数',
  1044. nameGap: 14,
  1045. nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
  1046. axisLabel: { color: '#8c8c8c', fontSize: 11 },
  1047. splitLine: { show: false },
  1048. axisLine: { show: false },
  1049. },
  1050. ],
  1051. series: [
  1052. {
  1053. name: '发放金额',
  1054. type: 'bar',
  1055. barMaxWidth: 36,
  1056. itemStyle: {
  1057. borderRadius: [2, 2, 0, 0],
  1058. color: {
  1059. type: 'linear',
  1060. x: 0,
  1061. y: 0,
  1062. x2: 0,
  1063. y2: 1,
  1064. colorStops: [
  1065. { offset: 0, color: '#4A8AFF' },
  1066. { offset: 1, color: '#2B73F8' },
  1067. ],
  1068. },
  1069. },
  1070. data: list.map((i) => i.value),
  1071. },
  1072. {
  1073. name: '发放人数',
  1074. type: 'line',
  1075. yAxisIndex: 1,
  1076. smooth: true,
  1077. symbol: 'circle',
  1078. symbolSize: 7,
  1079. lineStyle: { width: 2, color: '#1A56C4' },
  1080. itemStyle: { color: '#1A56C4', borderWidth: 2, borderColor: '#fff' },
  1081. data: list.map((i) => i.count ?? 0),
  1082. },
  1083. ],
  1084. }
  1085. })
  1086. function buildPieOption(
  1087. list: { name: string; value: number }[],
  1088. title: string,
  1089. ): EChartsCoreOption {
  1090. if (!list.length) {
  1091. return {
  1092. title: {
  1093. text: '暂无数据',
  1094. left: 'center',
  1095. top: 'middle',
  1096. textStyle: { color: '#8c8c8c', fontSize: 13, fontWeight: 400 },
  1097. },
  1098. series: [],
  1099. }
  1100. }
  1101. return {
  1102. color: CHART_COLORS,
  1103. tooltip: {
  1104. trigger: 'item',
  1105. formatter: '{b}: {c} ({d}%)',
  1106. backgroundColor: 'rgba(255,255,255,0.96)',
  1107. borderColor: '#d6e4ff',
  1108. textStyle: { color: '#333', fontSize: 12 },
  1109. },
  1110. legend: {
  1111. orient: 'vertical',
  1112. right: 12,
  1113. top: 'center',
  1114. itemWidth: 10,
  1115. itemHeight: 10,
  1116. itemGap: 12,
  1117. textStyle: { color: '#595959', fontSize: 12 },
  1118. },
  1119. series: [
  1120. {
  1121. name: title,
  1122. type: 'pie',
  1123. radius: ['42%', '68%'],
  1124. center: ['38%', '52%'],
  1125. data: list,
  1126. label: { formatter: '{d}%', color: '#595959', fontSize: 11 },
  1127. labelLine: {
  1128. length: 10,
  1129. length2: 8,
  1130. lineStyle: { color: '#d0c4b0' },
  1131. },
  1132. itemStyle: { borderColor: '#fff', borderWidth: 2 },
  1133. emphasis: {
  1134. scaleSize: 4,
  1135. itemStyle: { shadowBlur: 8, shadowColor: 'rgba(43,115,248,0.35)' },
  1136. },
  1137. },
  1138. ],
  1139. }
  1140. }
  1141. const statusPieMonthOption = computed(() =>
  1142. buildPieOption(data.value?.statusPieMonth ?? [], '本月状态'),
  1143. )
  1144. const statusPieYearOption = computed(() =>
  1145. buildPieOption(data.value?.statusPieYear ?? [], '年度状态'),
  1146. )
  1147. /** 区级:各街镇人数 */
  1148. const districtTownPeopleOption = computed(() =>
  1149. buildBarOption(
  1150. data.value?.districtOverview?.townPeopleChart ?? [],
  1151. '人数(人)',
  1152. (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
  1153. [
  1154. { offset: 0, color: '#a8b89a' },
  1155. { offset: 1, color: CHART_COLOR_GREEN },
  1156. ],
  1157. ),
  1158. )
  1159. /** 区级:有土地退养 */
  1160. const districtWithLandOption = computed(() =>
  1161. buildBarOption(
  1162. data.value?.districtOverview?.townWithLandChart ?? [],
  1163. '人数(人)',
  1164. (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
  1165. [
  1166. { offset: 0, color: '#a8b89a' },
  1167. { offset: 1, color: CHART_COLOR_GREEN },
  1168. ],
  1169. ),
  1170. )
  1171. /** 区级:无土地有资格 */
  1172. const districtWithoutLandOption = computed(() =>
  1173. buildBarOption(
  1174. data.value?.districtOverview?.townWithoutLandChart ?? [],
  1175. '人数(人)',
  1176. (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
  1177. [
  1178. { offset: 0, color: '#4A8AFF' },
  1179. { offset: 1, color: '#2B73F8' },
  1180. ],
  1181. ),
  1182. )
  1183. /** 区级:资格结构饼图 */
  1184. const districtQualifyPieOption = computed(() =>
  1185. buildPieOption(data.value?.districtOverview?.qualifyPie ?? [], '退养资格'),
  1186. )
  1187. /** 区级:近半年人数 */
  1188. const districtHalfYearPeopleOption = computed(() =>
  1189. buildBarOption(
  1190. data.value?.districtOverview?.halfYearPeopleChart ?? [],
  1191. '人数(人)',
  1192. (v) => `${Number(v).toLocaleString('zh-CN')} 人`,
  1193. [
  1194. { offset: 0, color: '#a8b89a' },
  1195. { offset: 1, color: CHART_COLOR_GREEN },
  1196. ],
  1197. ),
  1198. )
  1199. /** 区级:近半年金额 */
  1200. const districtHalfYearAmountOption = computed(() =>
  1201. buildBarOption(
  1202. data.value?.districtOverview?.halfYearAmountChart ?? [],
  1203. '金额(元)',
  1204. (v) => `¥${Number(v).toLocaleString('zh-CN')}`,
  1205. ),
  1206. )
  1207. /** 未读待办数 */
  1208. const unreadTodoCount = computed(
  1209. () => (data.value?.todos ?? []).filter((t) => !t.read).length,
  1210. )
  1211. const TODO_PAGE_SIZES = [5, 10, 20]
  1212. const {
  1213. page: todoPage,
  1214. pageSize: todoPageSize,
  1215. total: todoTotal,
  1216. pagedList: pagedTodos,
  1217. } = useClientPagination<TodoItem>(() => data.value?.todos ?? [], { pageSize: 5 })
  1218. const {
  1219. page: alertPage,
  1220. pageSize: alertPageSize,
  1221. total: alertTotal,
  1222. pagedList: pagedAlerts,
  1223. } = useClientPagination<AlertItem>(() => data.value?.recentAlerts ?? [], { pageSize: 5 })
  1224. function canHandleTodo(row: TodoItem) {
  1225. const path = row.path || ''
  1226. if (!path) return false
  1227. return userStore.canAccess(path)
  1228. }
  1229. /** 待办点击:标记已读并跳转业务页 */
  1230. async function onTodoClick(row: TodoItem) {
  1231. if (!row || !canHandleTodo(row)) return
  1232. const key = row.id || ''
  1233. if (key && !row.read) {
  1234. row.read = true
  1235. await markWorkbenchTodoRead(key)
  1236. if (data.value?.batchInfo) {
  1237. data.value.batchInfo.todoCount = unreadTodoCount.value
  1238. }
  1239. }
  1240. void router.push(row.path || '/approvals')
  1241. }
  1242. /** 切换发放周期(写入 store,各角色数据展板/业务页共用) */
  1243. function onMonthChange(month: string | null) {
  1244. if (!month) return
  1245. userStore.setMonth(month)
  1246. }
  1247. /** 按当前角色与发放周期加载数据展板(防抖 + 竞态序号) */
  1248. async function loadData(opts?: { forceLoading?: boolean }) {
  1249. const seq = ++loadSeq
  1250. // 仅首屏或切换角色/月份时盖 loading;定时静默刷新保留旧数据,避免整页长时间转圈
  1251. const blockUi = opts?.forceLoading || !data.value
  1252. const needVillagePayments =
  1253. (isTown.value || isSocial.value || isLeader.value) && !isVillage.value
  1254. if (blockUi) loading.value = true
  1255. try {
  1256. const lite = await fetchDashboard({
  1257. roleKey: userStore.currentRole,
  1258. currentMonth: userStore.currentMonth,
  1259. light: needVillagePayments,
  1260. })
  1261. if (seq !== loadSeq) return
  1262. data.value = lite
  1263. if (blockUi) loading.value = false
  1264. const hasVillagePay = (lite.townVillagePeopleChart || []).some((i) => Number(i.value) > 0)
  1265. if (needVillagePayments && !hasVillagePay) {
  1266. payTableLoading.value = true
  1267. try {
  1268. const charts = await fetchTownVillagePayTable({
  1269. roleKey: userStore.currentRole,
  1270. currentMonth: userStore.currentMonth,
  1271. })
  1272. if (seq !== loadSeq) return
  1273. if (data.value) {
  1274. data.value.townVillagePeopleChart = charts.people
  1275. data.value.townVillageAmountChart = charts.amount
  1276. data.value.townAmountChart = charts.amount
  1277. }
  1278. } finally {
  1279. if (seq === loadSeq) payTableLoading.value = false
  1280. }
  1281. }
  1282. } finally {
  1283. if (seq === loadSeq) loading.value = false
  1284. }
  1285. }
  1286. function scheduleLoad(forceLoading = false) {
  1287. if (loadTimer) clearTimeout(loadTimer)
  1288. loadTimer = setTimeout(() => {
  1289. loadTimer = null
  1290. void loadData({ forceLoading })
  1291. }, 80)
  1292. }
  1293. onMounted(() => {
  1294. void loadData({ forceLoading: true })
  1295. refreshTimer = setInterval(() => {
  1296. void loadData()
  1297. }, DASHBOARD_REFRESH_MS)
  1298. })
  1299. onUnmounted(() => {
  1300. if (loadTimer) clearTimeout(loadTimer)
  1301. if (refreshTimer) {
  1302. clearInterval(refreshTimer)
  1303. refreshTimer = null
  1304. }
  1305. })
  1306. // 切换角色 / 发放周期时刷新(短防抖,避免连点)
  1307. watch(
  1308. () => [userStore.currentRole, userStore.currentMonth],
  1309. () => {
  1310. data.value = null
  1311. scheduleLoad(true)
  1312. },
  1313. )
  1314. </script>
  1315. <style scoped lang="less">
  1316. .dashboard-view {
  1317. display: flex;
  1318. flex-direction: column;
  1319. gap: 12px;
  1320. }
  1321. .dashboard-todos-panel__count {
  1322. font-size: 12px;
  1323. color: #8a7a66;
  1324. }
  1325. .dashboard-todos-table {
  1326. cursor: pointer;
  1327. :deep(.is-todo-read) {
  1328. color: #9a8b7a;
  1329. }
  1330. .text-muted {
  1331. color: #b5a898;
  1332. }
  1333. }
  1334. .alert-ticker {
  1335. display: flex;
  1336. align-items: center;
  1337. gap: 10px;
  1338. min-height: 40px;
  1339. padding: 6px 10px;
  1340. background: #fff8ef;
  1341. border: 1px solid @border-light;
  1342. border-left: 3px solid #2B73F8;
  1343. &__label {
  1344. display: inline-flex;
  1345. align-items: center;
  1346. gap: 4px;
  1347. flex-shrink: 0;
  1348. font-size: 13px;
  1349. font-weight: 600;
  1350. color: @primary-dark;
  1351. em {
  1352. font-style: normal;
  1353. min-width: 18px;
  1354. height: 18px;
  1355. padding: 0 5px;
  1356. border-radius: 9px;
  1357. background: #c45c26;
  1358. color: #fff;
  1359. font-size: 11px;
  1360. line-height: 18px;
  1361. text-align: center;
  1362. }
  1363. }
  1364. &__track-wrap {
  1365. flex: 1;
  1366. min-width: 0;
  1367. overflow: hidden;
  1368. }
  1369. &__track {
  1370. display: flex;
  1371. width: max-content;
  1372. gap: 28px;
  1373. animation-name: alert-ticker-scroll;
  1374. animation-timing-function: linear;
  1375. animation-iteration-count: infinite;
  1376. &.is-paused {
  1377. animation-play-state: paused;
  1378. }
  1379. }
  1380. &__item {
  1381. display: inline-flex;
  1382. align-items: center;
  1383. gap: 8px;
  1384. border: 0;
  1385. background: transparent;
  1386. padding: 0;
  1387. cursor: pointer;
  1388. font: inherit;
  1389. color: @text-primary;
  1390. white-space: nowrap;
  1391. &:hover .alert-ticker__text {
  1392. color: @primary-dark;
  1393. text-decoration: underline;
  1394. }
  1395. }
  1396. &__type {
  1397. font-size: 12px;
  1398. color: #8a7350;
  1399. }
  1400. &__text {
  1401. font-size: 13px;
  1402. max-width: 420px;
  1403. overflow: hidden;
  1404. text-overflow: ellipsis;
  1405. }
  1406. &__empty {
  1407. font-size: 13px;
  1408. color: @text-secondary;
  1409. }
  1410. &__more {
  1411. flex-shrink: 0;
  1412. }
  1413. }
  1414. @keyframes alert-ticker-scroll {
  1415. from {
  1416. transform: translateX(0);
  1417. }
  1418. to {
  1419. transform: translateX(-50%);
  1420. }
  1421. }
  1422. .dashboard-stat-note {
  1423. margin: -4px 0 12px;
  1424. font-size: 12px;
  1425. color: @text-secondary;
  1426. line-height: 1.55;
  1427. }
  1428. /** 村 / 镇展板:表格与饼图并排,不再把饼图单独拉满一行 */
  1429. .dashboard-view--village {
  1430. min-height: 0;
  1431. }
  1432. /** 区农委 / 区人社局:全区监测工作台 */
  1433. .dashboard-view--district {
  1434. min-height: calc(100vh - @header-height - @content-padding * 2);
  1435. }
  1436. .district-workspace {
  1437. flex: 1;
  1438. min-height: 0;
  1439. display: flex;
  1440. flex-direction: column;
  1441. gap: 12px;
  1442. &__charts {
  1443. display: grid;
  1444. grid-template-columns: repeat(3, minmax(0, 1fr));
  1445. gap: 12px;
  1446. @media (max-width: 1200px) {
  1447. grid-template-columns: repeat(2, minmax(0, 1fr));
  1448. }
  1449. @media (max-width: 768px) {
  1450. grid-template-columns: 1fr;
  1451. }
  1452. :deep(.content-panel) {
  1453. display: flex;
  1454. flex-direction: column;
  1455. min-height: 280px;
  1456. }
  1457. }
  1458. &__chart-host {
  1459. flex: 1;
  1460. min-height: 220px;
  1461. }
  1462. }
  1463. /** 区级概况表:列宽均分,避免前几列挤左侧、金额飘到最右 */
  1464. .district-overview-panel {
  1465. :deep(.page-panel__body),
  1466. :deep(.content-panel__body) {
  1467. padding-top: 4px;
  1468. }
  1469. }
  1470. .district-overview-table {
  1471. width: 100%;
  1472. :deep(.el-table__header th),
  1473. :deep(.el-table__body td) {
  1474. padding: 8px 6px;
  1475. }
  1476. :deep(.el-table__header th.el-table__cell) {
  1477. background: #f5f2eb;
  1478. color: @text-regular;
  1479. font-weight: 600;
  1480. }
  1481. :deep(.el-table__body .cell) {
  1482. line-height: 1.4;
  1483. white-space: nowrap;
  1484. }
  1485. :deep(.el-table__row:hover > td.el-table__cell) {
  1486. background: #faf6ee !important;
  1487. }
  1488. }
  1489. .village-workspace {
  1490. display: flex;
  1491. flex-direction: column;
  1492. gap: 12px;
  1493. &__main {
  1494. display: grid;
  1495. grid-template-columns: minmax(0, 1fr);
  1496. grid-template-areas: 'pay';
  1497. gap: 12px;
  1498. align-items: start;
  1499. &.has-pie {
  1500. grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.9fr);
  1501. grid-template-areas: 'pay pie';
  1502. }
  1503. @media (max-width: 1100px) {
  1504. grid-template-columns: 1fr;
  1505. grid-template-areas: 'pay';
  1506. &.has-pie {
  1507. grid-template-areas:
  1508. 'pay'
  1509. 'pie';
  1510. }
  1511. }
  1512. :deep(.content-panel) {
  1513. margin-bottom: 0;
  1514. min-height: 0;
  1515. overflow: visible;
  1516. display: flex;
  1517. flex-direction: column;
  1518. }
  1519. :deep(.page-panel__title),
  1520. :deep(.block-title) {
  1521. flex-shrink: 0;
  1522. }
  1523. }
  1524. &__pie {
  1525. grid-area: pie;
  1526. }
  1527. &__pay {
  1528. grid-area: pay;
  1529. }
  1530. &__table-panel {
  1531. :deep(.el-table) {
  1532. width: 100%;
  1533. }
  1534. }
  1535. &__trend {
  1536. display: grid;
  1537. grid-template-columns: repeat(2, minmax(0, 1fr));
  1538. gap: 12px;
  1539. @media (max-width: 1100px) {
  1540. grid-template-columns: 1fr;
  1541. }
  1542. :deep(.content-panel) {
  1543. margin-bottom: 0;
  1544. }
  1545. }
  1546. &__chart-host {
  1547. height: 260px;
  1548. }
  1549. }
  1550. .batch-overview {
  1551. background: @block-bg;
  1552. border: 1px solid @border-light;
  1553. overflow: hidden;
  1554. &__head {
  1555. display: flex;
  1556. align-items: center;
  1557. justify-content: space-between;
  1558. gap: 12px;
  1559. padding: 12px 16px;
  1560. background: linear-gradient(90deg, #eef4ff 0%, #f7f9fc 55%, #fff 100%);
  1561. border-bottom: 1px solid #d6e4ff;
  1562. }
  1563. &__title {
  1564. display: flex;
  1565. align-items: flex-start;
  1566. gap: 10px;
  1567. min-width: 0;
  1568. h3 {
  1569. margin: 0;
  1570. font-size: 15px;
  1571. font-weight: 700;
  1572. color: @text-primary;
  1573. line-height: 1.3;
  1574. }
  1575. p {
  1576. margin: 4px 0 0;
  1577. font-size: 12px;
  1578. color: @text-secondary;
  1579. line-height: 1.4;
  1580. em {
  1581. font-style: normal;
  1582. font-weight: 600;
  1583. color: @primary-dark;
  1584. }
  1585. }
  1586. }
  1587. &__bar {
  1588. flex-shrink: 0;
  1589. width: 3px;
  1590. height: 28px;
  1591. margin-top: 2px;
  1592. background: linear-gradient(180deg, @brand-gold-deep, @primary-color);
  1593. border-radius: 1px;
  1594. }
  1595. &__period {
  1596. flex-shrink: 0;
  1597. display: flex;
  1598. align-items: center;
  1599. gap: 8px;
  1600. padding: 2px 6px 2px 10px;
  1601. font-size: 12px;
  1602. color: @primary-dark;
  1603. background: rgba(43, 115, 248, 0.12);
  1604. border: 1px solid rgba(43, 115, 248, 0.35);
  1605. border-radius: @radius-sm;
  1606. }
  1607. &__period-label {
  1608. flex-shrink: 0;
  1609. white-space: nowrap;
  1610. }
  1611. &__period-picker {
  1612. width: 120px;
  1613. :deep(.el-input__wrapper) {
  1614. background: #fff;
  1615. box-shadow: none;
  1616. border: 1px solid rgba(43, 115, 248, 0.45);
  1617. }
  1618. :deep(.el-input__inner) {
  1619. font-size: 12px;
  1620. color: @primary-dark;
  1621. font-weight: 600;
  1622. }
  1623. }
  1624. &__metrics {
  1625. display: grid;
  1626. grid-template-columns: repeat(4, 1fr);
  1627. gap: 0;
  1628. @media (max-width: 900px) {
  1629. grid-template-columns: repeat(2, 1fr);
  1630. }
  1631. }
  1632. }
  1633. .metric-item {
  1634. display: flex;
  1635. align-items: center;
  1636. gap: 10px;
  1637. padding: 16px 14px;
  1638. border-right: 1px solid @border-light;
  1639. transition: background 0.15s;
  1640. &:nth-child(4n),
  1641. &:last-child {
  1642. border-right: none;
  1643. }
  1644. @media (max-width: 900px) {
  1645. border-bottom: 1px solid @border-light;
  1646. &:nth-child(2n) {
  1647. border-right: none;
  1648. }
  1649. &:nth-child(n + 3) {
  1650. border-bottom: none;
  1651. }
  1652. }
  1653. &:hover {
  1654. background: @el-primary-light-9;
  1655. }
  1656. &__icon {
  1657. flex-shrink: 0;
  1658. width: 36px;
  1659. height: 36px;
  1660. display: flex;
  1661. align-items: center;
  1662. justify-content: center;
  1663. border-radius: @radius-sm;
  1664. font-size: 18px;
  1665. background: @el-primary-light-9;
  1666. color: @primary-color;
  1667. }
  1668. &__body {
  1669. min-width: 0;
  1670. }
  1671. &__label {
  1672. font-size: 12px;
  1673. color: @text-secondary;
  1674. margin-bottom: 4px;
  1675. white-space: nowrap;
  1676. }
  1677. &__value {
  1678. font-size: 18px;
  1679. font-weight: 700;
  1680. color: @text-primary;
  1681. line-height: 1.2;
  1682. word-break: break-all;
  1683. em {
  1684. margin-left: 2px;
  1685. font-size: 12px;
  1686. font-style: normal;
  1687. font-weight: 400;
  1688. color: @text-secondary;
  1689. }
  1690. }
  1691. &.tone-gold .metric-item__icon {
  1692. background: #e8f1ff;
  1693. color: @primary-dark;
  1694. }
  1695. &.tone-green .metric-item__icon {
  1696. background: @el-success-light-9;
  1697. color: @success-color;
  1698. }
  1699. &.tone-warn .metric-item__icon {
  1700. background: @el-warning-light-9;
  1701. color: @warning-color;
  1702. }
  1703. &.tone-danger .metric-item__icon {
  1704. background: @el-danger-light-9;
  1705. color: @danger-color;
  1706. }
  1707. &.tone-plain .metric-item__icon {
  1708. background: #f3f3f3;
  1709. color: @text-regular;
  1710. }
  1711. }
  1712. .dashboard-view__charts,
  1713. .dashboard-view__tables {
  1714. display: grid;
  1715. grid-template-columns: repeat(2, 1fr);
  1716. gap: 12px;
  1717. @media (max-width: 1100px) {
  1718. grid-template-columns: 1fr;
  1719. }
  1720. :deep(.content-panel) {
  1721. margin-bottom: 0;
  1722. height: 100%;
  1723. }
  1724. }
  1725. </style>