HomePage.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <script setup lang="ts">
  2. import { inject, onMounted, reactive, ref } from 'vue'
  3. import { advanceApi, reimburseApi } from '../../api'
  4. import { statusLabel, statusTone, formatMoney, moneyTableFormatter } from '../../utils/format'
  5. import { useServerPaged } from '../../hooks/useServerPaged'
  6. import BaseTag from '../../components/base/BaseTag.vue'
  7. import WorkflowNodeCell from '../../components/base/WorkflowNodeCell.vue'
  8. import BaseOpButton from '../../components/base/BaseOpButton.vue'
  9. import { openMenuTabKey } from '../../shell/openMenuTabKey'
  10. const openMenuTab = inject(openMenuTabKey, () => {})
  11. const loadingMap = reactive({
  12. stats: false,
  13. })
  14. const advanceStats = ref({
  15. totalCount: 0,
  16. pendingCount: 0,
  17. approvedPassCount: 0,
  18. rejectedCount: 0,
  19. issuedCount: 0,
  20. issuedAmount: 0,
  21. monthApplyAmount: 0,
  22. monthApplyCount: 0,
  23. pendingIssueCount: 0,
  24. offsetTotal: 0,
  25. })
  26. const reimburseStats = ref({
  27. totalCount: 0,
  28. pendingCount: 0,
  29. approvedCount: 0,
  30. rejectedCount: 0,
  31. approvedAmount: 0,
  32. monthApplyAmount: 0,
  33. monthApplyCount: 0,
  34. })
  35. const listTab = ref<'advance' | 'reimburse'>('advance')
  36. const {
  37. rows: advanceRows,
  38. total: advanceTotal,
  39. currentPage: advanceCurrentPage,
  40. pageSize: advancePageSize,
  41. loading: advanceLoading,
  42. rangeText: advanceRangeText,
  43. refresh: refreshAdvanceList,
  44. onSizeChange: onAdvanceSizeChange,
  45. } = useServerPaged(async (params) => {
  46. const res: any = await advanceApi.my(params)
  47. const d = res.data || {}
  48. return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
  49. })
  50. const {
  51. rows: reimburseRows,
  52. total: reimburseTotal,
  53. currentPage: reimburseCurrentPage,
  54. pageSize: reimbursePageSize,
  55. loading: reimburseLoading,
  56. rangeText: reimburseRangeText,
  57. refresh: refreshReimburseList,
  58. onSizeChange: onReimburseSizeChange,
  59. } = useServerPaged(async (params) => {
  60. const res: any = await reimburseApi.my(params)
  61. const d = res.data || {}
  62. return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
  63. })
  64. async function loadStats() {
  65. loadingMap.stats = true
  66. try {
  67. const [aRes, rRes]: any[] = await Promise.all([advanceApi.myStats(), reimburseApi.myStats()])
  68. const a = aRes.data || {}
  69. const r = rRes.data || {}
  70. advanceStats.value = {
  71. totalCount: Number(a.totalCount ?? 0),
  72. pendingCount: Number(a.pendingCount ?? 0),
  73. approvedPassCount: Number(a.approvedPassCount ?? 0),
  74. rejectedCount: Number(a.rejectedCount ?? 0),
  75. issuedCount: Number(a.issuedCount ?? 0),
  76. issuedAmount: Number(a.issuedAmount ?? 0),
  77. monthApplyAmount: Number(a.monthApplyAmount ?? 0),
  78. monthApplyCount: Number(a.monthApplyCount ?? 0),
  79. pendingIssueCount: Number(a.pendingIssueCount ?? 0),
  80. offsetTotal: Number(a.offsetTotal ?? 0),
  81. }
  82. reimburseStats.value = {
  83. totalCount: Number(r.totalCount ?? 0),
  84. pendingCount: Number(r.pendingCount ?? 0),
  85. approvedCount: Number(r.approvedCount ?? 0),
  86. rejectedCount: Number(r.rejectedCount ?? 0),
  87. approvedAmount: Number(r.approvedAmount ?? 0),
  88. monthApplyAmount: Number(r.monthApplyAmount ?? 0),
  89. monthApplyCount: Number(r.monthApplyCount ?? 0),
  90. }
  91. } finally {
  92. loadingMap.stats = false
  93. }
  94. }
  95. type TagTone = 'pending' | 'processing' | 'done' | 'warning' | 'error' | 'info'
  96. function elTagTone(s: unknown): TagTone {
  97. return statusTone(s) as TagTone
  98. }
  99. function goReimburseApply() {
  100. openMenuTab('reimburse-apply')
  101. }
  102. function goAdvanceApply() {
  103. openMenuTab('advance-apply')
  104. }
  105. onMounted(() => {
  106. loadStats()
  107. refreshAdvanceList()
  108. refreshReimburseList()
  109. })
  110. </script>
  111. <template>
  112. <div class="home-page">
  113. <div class="page-head">
  114. <h1>首页</h1>
  115. <p class="desc">个人备用金与报销概况、快捷发起与近期申请单。</p>
  116. </div>
  117. <div class="home-quick-row">
  118. <button type="button" class="home-quick-btn home-quick-btn--reim" @click="goReimburseApply">
  119. <span class="home-quick-bg" style="background-image: url(/2.png)" />
  120. <span class="home-quick-inner">
  121. <span class="home-quick-label">新增报销申请</span>
  122. </span>
  123. </button>
  124. <button type="button" class="home-quick-btn home-quick-btn--adv" @click="goAdvanceApply">
  125. <span class="home-quick-bg" style="background-image: url(/1.png)" />
  126. <span class="home-quick-inner">
  127. <span class="home-quick-label">新增备用金申请</span>
  128. </span>
  129. </button>
  130. </div>
  131. <div v-loading="loadingMap.stats" class="home-stats-row">
  132. <section class="home-stat-panel home-stat-panel--advance">
  133. <h2 class="home-stat-title">备用金申请</h2>
  134. <div class="home-stat-grid-wrap">
  135. <div class="home-stat-grid">
  136. <div class="home-stat-cell">
  137. <span class="k">申请总数</span>
  138. <span class="v">{{ advanceStats.totalCount }}</span>
  139. </div>
  140. <div class="home-stat-cell">
  141. <span class="k">审批中</span>
  142. <span class="v">{{ advanceStats.pendingCount }}</span>
  143. </div>
  144. <div class="home-stat-cell">
  145. <span class="k">已通过</span>
  146. <span class="v">{{ advanceStats.approvedPassCount }}</span>
  147. </div>
  148. <div class="home-stat-cell">
  149. <span class="k">已驳回</span>
  150. <span class="v">{{ advanceStats.rejectedCount }}</span>
  151. </div>
  152. <div class="home-stat-cell">
  153. <span class="k">已发放笔数</span>
  154. <span class="v">{{ advanceStats.issuedCount }}</span>
  155. </div>
  156. <div class="home-stat-cell">
  157. <span class="k">待发放</span>
  158. <span class="v">{{ advanceStats.pendingIssueCount }}</span>
  159. </div>
  160. <div class="home-stat-cell">
  161. <span class="k">已发放金额(元)</span>
  162. <span class="v">{{ formatMoney(advanceStats.issuedAmount) }}</span>
  163. </div>
  164. <div class="home-stat-cell">
  165. <span class="k">本月申请金额 / 笔数</span>
  166. <span class="v">{{ formatMoney(advanceStats.monthApplyAmount) }} / {{ advanceStats.monthApplyCount }}</span>
  167. </div>
  168. <div class="home-stat-cell">
  169. <span class="k">冲抵合计(元)</span>
  170. <span class="v">{{ formatMoney(advanceStats.offsetTotal) }}</span>
  171. </div>
  172. </div>
  173. </div>
  174. </section>
  175. <section class="home-stat-panel home-stat-panel--reimburse">
  176. <h2 class="home-stat-title">报销申请</h2>
  177. <div class="home-stat-grid-wrap">
  178. <div class="home-stat-grid">
  179. <div class="home-stat-cell">
  180. <span class="k">申请总数</span>
  181. <span class="v">{{ reimburseStats.totalCount }}</span>
  182. </div>
  183. <div class="home-stat-cell">
  184. <span class="k">审批中</span>
  185. <span class="v">{{ reimburseStats.pendingCount }}</span>
  186. </div>
  187. <div class="home-stat-cell">
  188. <span class="k">已通过</span>
  189. <span class="v">{{ reimburseStats.approvedCount }}</span>
  190. </div>
  191. <div class="home-stat-cell">
  192. <span class="k">已驳回</span>
  193. <span class="v">{{ reimburseStats.rejectedCount }}</span>
  194. </div>
  195. <div class="home-stat-cell">
  196. <span class="k">已通过金额(元)</span>
  197. <span class="v">{{ formatMoney(reimburseStats.approvedAmount) }}</span>
  198. </div>
  199. <div class="home-stat-cell">
  200. <span class="k">本月申请金额 / 笔数</span>
  201. <span class="v">{{ formatMoney(reimburseStats.monthApplyAmount) }} / {{ reimburseStats.monthApplyCount }}</span>
  202. </div>
  203. </div>
  204. </div>
  205. </section>
  206. </div>
  207. <div class="panel home-list-panel">
  208. <el-tabs v-model="listTab" class="home-tabs">
  209. <el-tab-pane label="我的备用金申请" name="advance">
  210. <div class="table-scroll" v-loading="advanceLoading">
  211. <el-table :data="advanceRows" stripe empty-text="暂无数据">
  212. <el-table-column type="index" label="序号" width="70" />
  213. <el-table-column prop="applyTime" label="申请时间" min-width="160" />
  214. <el-table-column prop="applyCode" label="申请单号" min-width="140" />
  215. <el-table-column prop="reason" label="事由" min-width="140" show-overflow-tooltip />
  216. <el-table-column prop="amount" label="金额(元)" width="120" :formatter="moneyTableFormatter" class-name="col-money" />
  217. <el-table-column prop="status" label="状态" width="110">
  218. <template #default="{ row }">
  219. <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
  220. </template>
  221. </el-table-column>
  222. <el-table-column prop="currentNode" label="当前节点" min-width="120">
  223. <template #default="{ row }">
  224. <WorkflowNodeCell :current-node="row.currentNode" :status="row.status" />
  225. </template>
  226. </el-table-column>
  227. <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
  228. <template #default>
  229. <BaseOpButton primary @click="openMenuTab('advance-apply')">去办理</BaseOpButton>
  230. </template>
  231. </el-table-column>
  232. </el-table>
  233. </div>
  234. <div class="list-pager">
  235. <span class="list-range">{{ advanceRangeText }}</span>
  236. <div class="list-pager-right">
  237. <el-pagination
  238. v-model:current-page="advanceCurrentPage"
  239. v-model:page-size="advancePageSize"
  240. :total="advanceTotal"
  241. layout="sizes, prev, pager, next"
  242. :page-sizes="[10, 20, 50]"
  243. prev-text="上一页"
  244. next-text="下一页"
  245. @size-change="onAdvanceSizeChange"
  246. />
  247. <span class="pager-size-suffix">条</span>
  248. </div>
  249. </div>
  250. </el-tab-pane>
  251. <el-tab-pane label="我的报销申请" name="reimburse">
  252. <div class="table-scroll" v-loading="reimburseLoading">
  253. <el-table :data="reimburseRows" stripe empty-text="暂无数据">
  254. <el-table-column type="index" label="序号" width="70" />
  255. <el-table-column prop="applyTime" label="申请时间" min-width="160" />
  256. <el-table-column prop="applyCode" label="申请单号" min-width="140" />
  257. <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
  258. <el-table-column prop="expenseName" label="费用项目" min-width="120" show-overflow-tooltip />
  259. <el-table-column prop="amount" label="金额(元)" width="120" :formatter="moneyTableFormatter" class-name="col-money" />
  260. <el-table-column prop="status" label="状态" width="110">
  261. <template #default="{ row }">
  262. <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
  263. </template>
  264. </el-table-column>
  265. <el-table-column prop="currentNode" label="当前节点" min-width="120">
  266. <template #default="{ row }">
  267. <WorkflowNodeCell :current-node="row.currentNode" :status="row.status" />
  268. </template>
  269. </el-table-column>
  270. <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
  271. <template #default>
  272. <BaseOpButton primary @click="openMenuTab('reimburse-apply')">去办理</BaseOpButton>
  273. </template>
  274. </el-table-column>
  275. </el-table>
  276. </div>
  277. <div class="list-pager">
  278. <span class="list-range">{{ reimburseRangeText }}</span>
  279. <div class="list-pager-right">
  280. <el-pagination
  281. v-model:current-page="reimburseCurrentPage"
  282. v-model:page-size="reimbursePageSize"
  283. :total="reimburseTotal"
  284. layout="sizes, prev, pager, next"
  285. :page-sizes="[10, 20, 50]"
  286. prev-text="上一页"
  287. next-text="下一页"
  288. @size-change="onReimburseSizeChange"
  289. />
  290. <span class="pager-size-suffix">条</span>
  291. </div>
  292. </div>
  293. </el-tab-pane>
  294. </el-tabs>
  295. </div>
  296. </div>
  297. </template>
  298. <style scoped>
  299. .home-page {
  300. padding-bottom: 24px;
  301. }
  302. .home-stats-row {
  303. display: grid;
  304. grid-template-columns: 1fr 1fr;
  305. gap: 16px;
  306. margin-bottom: 20px;
  307. }
  308. @media (max-width: 1100px) {
  309. .home-stats-row {
  310. grid-template-columns: 1fr;
  311. }
  312. }
  313. .home-stat-panel {
  314. background: var(--surface);
  315. border: 1px solid var(--border);
  316. border-radius: var(--radius);
  317. box-shadow: var(--shadow);
  318. padding: 16px 18px;
  319. }
  320. .home-stat-title {
  321. margin: 0 0 12px;
  322. font-size: 15px;
  323. font-weight: 600;
  324. color: var(--text);
  325. }
  326. .home-stat-grid-wrap {
  327. min-width: 0;
  328. overflow-x: auto;
  329. -webkit-overflow-scrolling: touch;
  330. }
  331. /* 备用金:9 项固定两行(5 + 4) */
  332. .home-stat-panel--advance .home-stat-grid {
  333. display: grid;
  334. grid-template-columns: repeat(5, minmax(72px, 1fr));
  335. grid-template-rows: repeat(2, auto);
  336. gap: 8px 10px;
  337. }
  338. /* 报销:6 项固定两行(3 + 3) */
  339. .home-stat-panel--reimburse .home-stat-grid {
  340. display: grid;
  341. grid-template-columns: repeat(3, minmax(80px, 1fr));
  342. grid-template-rows: repeat(2, auto);
  343. gap: 8px 10px;
  344. }
  345. .home-stat-cell {
  346. display: flex;
  347. flex-direction: column;
  348. gap: 2px;
  349. min-width: 0;
  350. padding: 8px 10px;
  351. background: var(--table-head);
  352. border-radius: var(--radius-md);
  353. border: 1px solid var(--el-border-color-light);
  354. }
  355. .home-stat-panel--advance .home-stat-title {
  356. color: var(--primary-dark);
  357. }
  358. .home-stat-panel--reimburse .home-stat-title {
  359. color: #0369a1;
  360. }
  361. .home-stat-cell .k {
  362. font-size: 12px;
  363. line-height: 1.3;
  364. color: var(--text-muted);
  365. display: -webkit-box;
  366. -webkit-box-orient: vertical;
  367. -webkit-line-clamp: 2;
  368. overflow: hidden;
  369. word-break: break-word;
  370. }
  371. .home-stat-cell .v {
  372. font-size: 17px;
  373. font-weight: 600;
  374. color: var(--text);
  375. line-height: 1.25;
  376. word-break: break-all;
  377. }
  378. .home-quick-row {
  379. display: grid;
  380. grid-template-columns: 1fr 1fr;
  381. gap: 16px;
  382. margin-bottom: 20px;
  383. }
  384. @media (max-width: 560px) {
  385. .home-quick-row {
  386. grid-template-columns: 1fr;
  387. }
  388. }
  389. .home-quick-btn {
  390. position: relative;
  391. display: block;
  392. width: 100%;
  393. min-height: 140px;
  394. padding: 0;
  395. border: none;
  396. border-radius: var(--radius);
  397. overflow: hidden;
  398. cursor: pointer;
  399. text-align: left;
  400. box-shadow: var(--shadow);
  401. }
  402. .home-quick-btn:disabled {
  403. opacity: 0.75;
  404. cursor: wait;
  405. }
  406. .home-quick-bg {
  407. position: absolute;
  408. inset: 0;
  409. background-size: cover;
  410. background-position: center right;
  411. transition: transform 0.25s ease;
  412. }
  413. .home-quick-btn:hover .home-quick-bg {
  414. transform: scale(1.02);
  415. }
  416. .home-quick-inner {
  417. position: relative;
  418. z-index: 1;
  419. display: flex;
  420. align-items: flex-start;
  421. justify-content: flex-start;
  422. min-height: 140px;
  423. padding: 20px 22px;
  424. background: transparent;
  425. }
  426. .home-quick-label {
  427. font-size: 22px;
  428. font-weight: 700;
  429. letter-spacing: 0.02em;
  430. color: #fff;
  431. }
  432. @media (max-width: 560px) {
  433. .home-quick-label {
  434. font-size: 18px;
  435. }
  436. }
  437. .home-list-panel {
  438. padding: 8px 16px 16px;
  439. background: var(--surface);
  440. border: 1px solid var(--border);
  441. border-radius: var(--radius);
  442. box-shadow: var(--shadow);
  443. }
  444. .home-tabs :deep(.el-tabs__header) {
  445. margin-bottom: 12px;
  446. }
  447. .home-tabs :deep(.el-tabs__item) {
  448. font-size: 14px;
  449. }
  450. </style>