|
|
@@ -0,0 +1,502 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { inject, onMounted, reactive, ref } from 'vue'
|
|
|
+import { advanceApi, reimburseApi } from '../../api'
|
|
|
+import { statusLabel, statusTone, hasWorkflowNode } from '../../utils/format'
|
|
|
+import { useServerPaged } from '../../hooks/useServerPaged'
|
|
|
+import BaseTag from '../../components/base/BaseTag.vue'
|
|
|
+import BaseOpButton from '../../components/base/BaseOpButton.vue'
|
|
|
+import { openMenuTabKey } from '../../shell/openMenuTabKey'
|
|
|
+
|
|
|
+const openMenuTab = inject(openMenuTabKey, () => {})
|
|
|
+
|
|
|
+const loadingMap = reactive({
|
|
|
+ stats: false,
|
|
|
+})
|
|
|
+
|
|
|
+const advanceStats = ref({
|
|
|
+ totalCount: 0,
|
|
|
+ pendingCount: 0,
|
|
|
+ approvedPassCount: 0,
|
|
|
+ rejectedCount: 0,
|
|
|
+ issuedCount: 0,
|
|
|
+ issuedAmount: 0,
|
|
|
+ monthApplyAmount: 0,
|
|
|
+ monthApplyCount: 0,
|
|
|
+ pendingIssueCount: 0,
|
|
|
+ offsetTotal: 0,
|
|
|
+})
|
|
|
+
|
|
|
+const reimburseStats = ref({
|
|
|
+ totalCount: 0,
|
|
|
+ pendingCount: 0,
|
|
|
+ approvedCount: 0,
|
|
|
+ rejectedCount: 0,
|
|
|
+ approvedAmount: 0,
|
|
|
+ monthApplyAmount: 0,
|
|
|
+ monthApplyCount: 0,
|
|
|
+})
|
|
|
+
|
|
|
+const listTab = ref<'advance' | 'reimburse'>('advance')
|
|
|
+
|
|
|
+const {
|
|
|
+ rows: advanceRows,
|
|
|
+ total: advanceTotal,
|
|
|
+ currentPage: advanceCurrentPage,
|
|
|
+ pageSize: advancePageSize,
|
|
|
+ loading: advanceLoading,
|
|
|
+ rangeText: advanceRangeText,
|
|
|
+ refresh: refreshAdvanceList,
|
|
|
+ onSizeChange: onAdvanceSizeChange,
|
|
|
+} = useServerPaged(async (params) => {
|
|
|
+ const res: any = await advanceApi.my(params)
|
|
|
+ const d = res.data || {}
|
|
|
+ return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
|
|
|
+})
|
|
|
+
|
|
|
+const {
|
|
|
+ rows: reimburseRows,
|
|
|
+ total: reimburseTotal,
|
|
|
+ currentPage: reimburseCurrentPage,
|
|
|
+ pageSize: reimbursePageSize,
|
|
|
+ loading: reimburseLoading,
|
|
|
+ rangeText: reimburseRangeText,
|
|
|
+ refresh: refreshReimburseList,
|
|
|
+ onSizeChange: onReimburseSizeChange,
|
|
|
+} = useServerPaged(async (params) => {
|
|
|
+ const res: any = await reimburseApi.my(params)
|
|
|
+ const d = res.data || {}
|
|
|
+ return { rows: d.rows ?? [], total: Number(d.total ?? 0) }
|
|
|
+})
|
|
|
+
|
|
|
+async function loadStats() {
|
|
|
+ loadingMap.stats = true
|
|
|
+ try {
|
|
|
+ const [aRes, rRes]: any[] = await Promise.all([advanceApi.myStats(), reimburseApi.myStats()])
|
|
|
+ const a = aRes.data || {}
|
|
|
+ const r = rRes.data || {}
|
|
|
+ advanceStats.value = {
|
|
|
+ totalCount: Number(a.totalCount ?? 0),
|
|
|
+ pendingCount: Number(a.pendingCount ?? 0),
|
|
|
+ approvedPassCount: Number(a.approvedPassCount ?? 0),
|
|
|
+ rejectedCount: Number(a.rejectedCount ?? 0),
|
|
|
+ issuedCount: Number(a.issuedCount ?? 0),
|
|
|
+ issuedAmount: Number(a.issuedAmount ?? 0),
|
|
|
+ monthApplyAmount: Number(a.monthApplyAmount ?? 0),
|
|
|
+ monthApplyCount: Number(a.monthApplyCount ?? 0),
|
|
|
+ pendingIssueCount: Number(a.pendingIssueCount ?? 0),
|
|
|
+ offsetTotal: Number(a.offsetTotal ?? 0),
|
|
|
+ }
|
|
|
+ reimburseStats.value = {
|
|
|
+ totalCount: Number(r.totalCount ?? 0),
|
|
|
+ pendingCount: Number(r.pendingCount ?? 0),
|
|
|
+ approvedCount: Number(r.approvedCount ?? 0),
|
|
|
+ rejectedCount: Number(r.rejectedCount ?? 0),
|
|
|
+ approvedAmount: Number(r.approvedAmount ?? 0),
|
|
|
+ monthApplyAmount: Number(r.monthApplyAmount ?? 0),
|
|
|
+ monthApplyCount: Number(r.monthApplyCount ?? 0),
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ loadingMap.stats = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function money(n: number) {
|
|
|
+ return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
|
+}
|
|
|
+
|
|
|
+type TagTone = 'pending' | 'processing' | 'done' | 'warning' | 'error' | 'info'
|
|
|
+
|
|
|
+function elTagTone(s: unknown): TagTone {
|
|
|
+ return statusTone(s) as TagTone
|
|
|
+}
|
|
|
+
|
|
|
+function goReimburseApply() {
|
|
|
+ openMenuTab('reimburse-apply')
|
|
|
+}
|
|
|
+
|
|
|
+function goAdvanceApply() {
|
|
|
+ openMenuTab('advance-apply')
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadStats()
|
|
|
+ refreshAdvanceList()
|
|
|
+ refreshReimburseList()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="home-page">
|
|
|
+ <div class="page-head">
|
|
|
+ <h1>首页</h1>
|
|
|
+ <p class="desc">个人备用金与报销概况、快捷发起与近期申请单。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="home-quick-row">
|
|
|
+ <button type="button" class="home-quick-btn home-quick-btn--reim" @click="goReimburseApply">
|
|
|
+ <span class="home-quick-bg" style="background-image: url(/2.png)" />
|
|
|
+ <span class="home-quick-inner">
|
|
|
+ <span class="home-quick-label">新增报销申请</span>
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="home-quick-btn home-quick-btn--adv" @click="goAdvanceApply">
|
|
|
+ <span class="home-quick-bg" style="background-image: url(/1.png)" />
|
|
|
+ <span class="home-quick-inner">
|
|
|
+ <span class="home-quick-label">新增备用金申请</span>
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-loading="loadingMap.stats" class="home-stats-row">
|
|
|
+ <section class="home-stat-panel home-stat-panel--advance">
|
|
|
+ <h2 class="home-stat-title">备用金申请</h2>
|
|
|
+ <div class="home-stat-grid-wrap">
|
|
|
+ <div class="home-stat-grid">
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">申请总数</span>
|
|
|
+ <span class="v">{{ advanceStats.totalCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">审批中</span>
|
|
|
+ <span class="v">{{ advanceStats.pendingCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已通过</span>
|
|
|
+ <span class="v">{{ advanceStats.approvedPassCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已驳回</span>
|
|
|
+ <span class="v">{{ advanceStats.rejectedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已发放笔数</span>
|
|
|
+ <span class="v">{{ advanceStats.issuedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">待发放</span>
|
|
|
+ <span class="v">{{ advanceStats.pendingIssueCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已发放金额(元)</span>
|
|
|
+ <span class="v">{{ money(advanceStats.issuedAmount) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">本月申请金额 / 笔数</span>
|
|
|
+ <span class="v">{{ money(advanceStats.monthApplyAmount) }} / {{ advanceStats.monthApplyCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">冲抵合计(元)</span>
|
|
|
+ <span class="v">{{ money(advanceStats.offsetTotal) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="home-stat-panel home-stat-panel--reimburse">
|
|
|
+ <h2 class="home-stat-title">报销申请</h2>
|
|
|
+ <div class="home-stat-grid-wrap">
|
|
|
+ <div class="home-stat-grid">
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">申请总数</span>
|
|
|
+ <span class="v">{{ reimburseStats.totalCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">审批中</span>
|
|
|
+ <span class="v">{{ reimburseStats.pendingCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已通过</span>
|
|
|
+ <span class="v">{{ reimburseStats.approvedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已驳回</span>
|
|
|
+ <span class="v">{{ reimburseStats.rejectedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">已通过金额(元)</span>
|
|
|
+ <span class="v">{{ money(reimburseStats.approvedAmount) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="home-stat-cell">
|
|
|
+ <span class="k">本月申请金额 / 笔数</span>
|
|
|
+ <span class="v">{{ money(reimburseStats.monthApplyAmount) }} / {{ reimburseStats.monthApplyCount }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="panel home-list-panel">
|
|
|
+ <el-tabs v-model="listTab" class="home-tabs">
|
|
|
+ <el-tab-pane label="我的备用金申请" name="advance">
|
|
|
+ <div class="table-scroll" v-loading="advanceLoading">
|
|
|
+ <el-table :data="advanceRows" stripe empty-text="暂无数据">
|
|
|
+ <el-table-column type="index" label="序号" width="70" />
|
|
|
+ <el-table-column prop="applyCode" label="申请单号" min-width="140" />
|
|
|
+ <el-table-column prop="reason" label="事由" min-width="140" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="amount" label="金额(元)" width="120" class-name="col-money" />
|
|
|
+ <el-table-column prop="status" label="状态" width="110">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="currentNode" label="当前节点" min-width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <BaseTag v-if="hasWorkflowNode(row.currentNode)" :tone="elTagTone(row.currentNode)">
|
|
|
+ {{ statusLabel(row.currentNode) }}
|
|
|
+ </BaseTag>
|
|
|
+ <span v-else class="node-current-empty">-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="applyTime" label="申请时间" min-width="160" />
|
|
|
+ <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
|
|
|
+ <template #default>
|
|
|
+ <BaseOpButton primary @click="openMenuTab('advance-apply')">去办理</BaseOpButton>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+ <div class="list-pager">
|
|
|
+ <span class="list-range">{{ advanceRangeText }}</span>
|
|
|
+ <div class="list-pager-right">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="advanceCurrentPage"
|
|
|
+ v-model:page-size="advancePageSize"
|
|
|
+ :total="advanceTotal"
|
|
|
+ layout="sizes, prev, pager, next"
|
|
|
+ :page-sizes="[10, 20, 50]"
|
|
|
+ prev-text="上一页"
|
|
|
+ next-text="下一页"
|
|
|
+ @size-change="onAdvanceSizeChange"
|
|
|
+ />
|
|
|
+ <span class="pager-size-suffix">条</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-tab-pane>
|
|
|
+
|
|
|
+ <el-tab-pane label="我的报销申请" name="reimburse">
|
|
|
+ <div class="table-scroll" v-loading="reimburseLoading">
|
|
|
+ <el-table :data="reimburseRows" stripe empty-text="暂无数据">
|
|
|
+ <el-table-column type="index" label="序号" width="70" />
|
|
|
+ <el-table-column prop="applyCode" label="申请单号" min-width="140" />
|
|
|
+ <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="expenseName" label="费用项目" min-width="120" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="amount" label="金额(元)" width="120" class-name="col-money" />
|
|
|
+ <el-table-column prop="status" label="状态" width="110">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <BaseTag :tone="elTagTone(row.status)">{{ statusLabel(row.status) }}</BaseTag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="currentNode" label="当前节点" min-width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <BaseTag v-if="hasWorkflowNode(row.currentNode)" :tone="elTagTone(row.currentNode)">
|
|
|
+ {{ statusLabel(row.currentNode) }}
|
|
|
+ </BaseTag>
|
|
|
+ <span v-else class="node-current-empty">-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="applyTime" label="申请时间" min-width="160" />
|
|
|
+ <el-table-column label="操作" width="120" fixed="right" class-name="col-actions">
|
|
|
+ <template #default>
|
|
|
+ <BaseOpButton primary @click="openMenuTab('reimburse-apply')">去办理</BaseOpButton>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+ <div class="list-pager">
|
|
|
+ <span class="list-range">{{ reimburseRangeText }}</span>
|
|
|
+ <div class="list-pager-right">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="reimburseCurrentPage"
|
|
|
+ v-model:page-size="reimbursePageSize"
|
|
|
+ :total="reimburseTotal"
|
|
|
+ layout="sizes, prev, pager, next"
|
|
|
+ :page-sizes="[10, 20, 50]"
|
|
|
+ prev-text="上一页"
|
|
|
+ next-text="下一页"
|
|
|
+ @size-change="onReimburseSizeChange"
|
|
|
+ />
|
|
|
+ <span class="pager-size-suffix">条</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-tab-pane>
|
|
|
+ </el-tabs>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.home-page {
|
|
|
+ padding-bottom: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.home-stats-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 16px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 1100px) {
|
|
|
+ .home-stats-row {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-panel {
|
|
|
+ background: var(--surface);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ box-shadow: var(--shadow);
|
|
|
+ padding: 16px 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-title {
|
|
|
+ margin: 0 0 12px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--text);
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-grid-wrap {
|
|
|
+ min-width: 0;
|
|
|
+ overflow-x: auto;
|
|
|
+ -webkit-overflow-scrolling: touch;
|
|
|
+}
|
|
|
+
|
|
|
+/* 备用金:9 项固定两行(5 + 4) */
|
|
|
+.home-stat-panel--advance .home-stat-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(5, minmax(72px, 1fr));
|
|
|
+ grid-template-rows: repeat(2, auto);
|
|
|
+ gap: 8px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 报销:6 项固定两行(3 + 3) */
|
|
|
+.home-stat-panel--reimburse .home-stat-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, minmax(80px, 1fr));
|
|
|
+ grid-template-rows: repeat(2, auto);
|
|
|
+ gap: 8px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-cell {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 2px;
|
|
|
+ min-width: 0;
|
|
|
+ padding: 8px 10px;
|
|
|
+ background: var(--table-head);
|
|
|
+ border-radius: var(--radius-md);
|
|
|
+ border: 1px solid var(--el-border-color-light);
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-panel--advance .home-stat-title {
|
|
|
+ color: var(--primary-dark);
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-panel--reimburse .home-stat-title {
|
|
|
+ color: #0369a1;
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-cell .k {
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.3;
|
|
|
+ color: var(--text-muted);
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ overflow: hidden;
|
|
|
+ word-break: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.home-stat-cell .v {
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--text);
|
|
|
+ line-height: 1.25;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 16px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 560px) {
|
|
|
+ .home-quick-row {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-btn {
|
|
|
+ position: relative;
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 140px;
|
|
|
+ padding: 0;
|
|
|
+ border: none;
|
|
|
+ border-radius: var(--radius);
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ text-align: left;
|
|
|
+ box-shadow: var(--shadow);
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-btn:disabled {
|
|
|
+ opacity: 0.75;
|
|
|
+ cursor: wait;
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-bg {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ background-size: cover;
|
|
|
+ background-position: center right;
|
|
|
+ transition: transform 0.25s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-btn:hover .home-quick-bg {
|
|
|
+ transform: scale(1.02);
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-inner {
|
|
|
+ position: relative;
|
|
|
+ z-index: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 140px;
|
|
|
+ padding: 20px 22px;
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.home-quick-label {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.02em;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 560px) {
|
|
|
+ .home-quick-label {
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.home-list-panel {
|
|
|
+ padding: 8px 16px 16px;
|
|
|
+ background: var(--surface);
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius);
|
|
|
+ box-shadow: var(--shadow);
|
|
|
+}
|
|
|
+
|
|
|
+.home-tabs :deep(.el-tabs__header) {
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.home-tabs :deep(.el-tabs__item) {
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+</style>
|