Przeglądaj źródła

轮胎优惠券大屏

liyuan 2 tygodni temu
rodzic
commit
206394f40e

+ 47 - 0
src/api/redPacket/screen.js

@@ -0,0 +1,47 @@
+import request from "@/router/axios";
+
+/**
+ * 优惠券大屏:不传 corpsId;可选 yearMonth、tenantId(从「租户」下拉选择,列表见 getCustomerList)
+ * - 张数:本门店 tire_user_coupon 等;领取/使用
+ * - 金额:pjpf_order.red_packet_amount,按订单支付状态拆分:已支付实付 vs 待支付占用(未付但已用券)
+ * - 店内排行:客户或业务员等维度,谁用得多
+ * - 统计维度:yearMonth 可选;不传或空表示「全部」累计;传 "yyyy-MM" 为自然月
+ * - 未选租户时也可不传 tenantId,由后端按 token 默认租户处理
+ */
+export function getCustomerList() {
+    return request({
+        url: "/api/blade-sales-part/tire/coupon/screen/getCustomerList",
+        method: "post",
+        timeout: 15000
+    });
+}
+
+export function getCouponScreenOverview(params) {
+    return request({
+        url: "/api/blade-sales-part/tire/coupon/screen/overview",
+        method: "post",
+        data: params || {},
+        timeout: 30000
+    });
+}
+
+/** 趋势(所选自然月内按日):paidRedPacketAmount / unpaidRedPacketAmount、receivedCount / usedCount */
+export function getCouponAcquireUseTrend(params) {
+    return request({
+        url: "/api/blade-sales-part/tire/coupon/screen/acquire-use-trend",
+        method: "post",
+        data: params || {},
+        timeout: 20000
+    });
+}
+
+/** 使用排行导出,body 与 overview 一致(yearMonth、tenantId 等) */
+export function exportUsageRank(params) {
+    return request({
+        url: "/api/blade-sales-part/tire/coupon/screen/export-usage-rank",
+        method: "post",
+        data: params || {},
+        responseType: "blob",
+        timeout: 120000
+    });
+}

+ 1262 - 0
src/views/tirePartsMall/statisticAnalysis/red-packet/screen.vue

@@ -0,0 +1,1262 @@
+<template>
+    <div class="dashboard-container">
+        <div class="header">
+            <div class="header-title-wrap">
+                <h1>优惠券运营大屏</h1>
+                <p v-if="storeName" class="header-sub">{{ storeName }}</p>
+            </div>
+            <div class="time-info">
+                <span class="month-label">客户</span>
+                <el-select
+                    v-model="selectedTenantId"
+                    size="small"
+                    placeholder="请选择客户"
+                    class="dark-select tenant-select"
+                    popper-class="dark-select-popper"
+                    filterable
+                    clearable
+                    :loading="tenantListLoading"
+                    :disabled="tenantListLoading && !tenantList.length"
+                    @change="handleTenantChange"
+                >
+                    <el-option
+                        v-for="item in tenantList"
+                        :key="String(item.tenantId)"
+                        :label="item.tenantName"
+                        :value="item.tenantId"
+                    />
+                </el-select>
+                <span class="month-label">统计月份</span>
+                <el-date-picker
+                    v-model="statsMonth"
+                    type="month"
+                    size="small"
+                    value-format="yyyy-MM"
+                    clearable
+                    placeholder="全部"
+                    class="dark-month-picker"
+                    popper-class="dark-select-popper"
+                    @change="handleStatsMonthChange"
+                />
+                <span>数据更新时间:{{ updateTime }}</span>
+                <el-button type="text" @click="refreshData" :loading="refreshing">
+                    {{ refreshing ? '刷新中...' : '刷新' }}
+                </el-button>
+            </div>
+        </div>
+
+        <div class="core-metrics">
+            <el-row :gutter="16" class="metrics-row">
+                <el-col :xs="24" :sm="12" :lg="6">
+                    <metric-card
+                        :title="'领取张数(' + statsMonthLabel + ')'"
+                        :value="kpis.totalReceived"
+                        icon="el-icon-download"
+                        color="#67C23A"
+                        suffix="张"
+                        :loading="loading"
+                    />
+                </el-col>
+                <el-col :xs="24" :sm="12" :lg="6">
+                    <metric-card
+                        :title="'已使用张数(' + statsMonthLabel + ')'"
+                        :value="kpis.totalUsed"
+                        icon="el-icon-circle-check"
+                        color="#409EFF"
+                        suffix="张"
+                        :loading="loading"
+                    />
+                </el-col>
+                <el-col :xs="24" :sm="12" :lg="6">
+                    <metric-card
+                        :title="'已支付红包金额(' + statsMonthLabel + ')'"
+                        :value="kpis.paidRedPacketAmount"
+                        icon="el-icon-money"
+                        color="#E6A23C"
+                        prefix="¥"
+                        :formatter="formatCurrency"
+                        :loading="loading"
+                    />
+                </el-col>
+                <el-col :xs="24" :sm="12" :lg="6">
+                    <metric-card
+                        :title="'待支付占用(' + statsMonthLabel + ')'"
+                        :value="kpis.unpaidRedPacketAmount"
+                        icon="el-icon-warning"
+                        color="#909399"
+                        prefix="¥"
+                        :formatter="formatCurrency"
+                        :loading="loading"
+                    />
+                </el-col>
+            </el-row>
+        </div>
+
+        <div class="chart-section">
+            <el-row :gutter="20">
+                <el-col :span="12">
+                    <div class="chart-card">
+                        <div class="chart-header chart-header--col">
+                            <div>
+                                <h3>红包金额({{ statsMonthLabel }})</h3>
+                                <p v-if="!statsMonth" class="chart-desc">统计范围:{{ statsMonthLabel }}</p>
+                            </div>
+                        </div>
+                        <div class="chart-container chart-container--echarts">
+                            <div ref="amountStatusChart" class="chart-instance"></div>
+                            <div v-show="trendLoading" class="chart-loading chart-loading--overlay">
+                                <i class="el-icon-loading"></i>
+                                <span>加载中...</span>
+                            </div>
+                        </div>
+                    </div>
+                </el-col>
+                <el-col :span="12">
+                    <div class="chart-card">
+                        <div class="chart-header chart-header--col">
+                            <div>
+                                <h3>领取 / 使用张数({{ statsMonthLabel }} · 按日)</h3>
+                                <p v-if="!statsMonth" class="chart-desc">统计范围:{{ statsMonthLabel }}</p>
+                            </div>
+                        </div>
+                        <div class="chart-container chart-container--echarts">
+                            <div ref="receiveUseChart" class="chart-instance"></div>
+                            <div v-show="chartLoading" class="chart-loading chart-loading--overlay">
+                                <i class="el-icon-loading"></i>
+                                <span>加载中...</span>
+                            </div>
+                        </div>
+                    </div>
+                </el-col>
+            </el-row>
+
+            <el-row :gutter="20" style="margin-top: 20px;">
+                <el-col :span="24">
+                    <div class="chart-card">
+                        <div class="chart-header">
+                            <div>
+                                <h3>使用排行</h3>
+                                <p class="chart-desc">按红包金额合计(已付+待付)降序 · 统计范围:{{ statsMonthLabel }} · 与上方一致</p>
+                            </div>
+                            <el-button type="text" :loading="exportRankLoading" @click="exportUsageRank">
+                                {{ exportRankLoading ? '导出中...' : '导出' }}
+                            </el-button>
+                        </div>
+                        <el-table
+                            :data="usageRank"
+                            style="width: 100%"
+                            height="320"
+                            border
+                            class="dark-table"
+                            v-loading="tableLoading"
+                            element-loading-text="数据加载中"
+                            element-loading-background="rgba(0, 0, 0, 0.5)"
+                        >
+                            <el-table-column type="index" label="#" width="48" align="center" />
+                            <el-table-column
+                                prop="name"
+                                label="客户/业务员"
+                                min-width="160"
+                                show-overflow-tooltip
+                                tooltip-class="dark-tooltip-popper"
+                            />
+                            <el-table-column
+                                prop="salesCompanyName"
+                                label="公司名称"
+                                min-width="160"
+                                show-overflow-tooltip
+                                tooltip-class="dark-tooltip-popper"
+                            />
+                            <el-table-column prop="usedCount" label="订单数" width="88" align="right" />
+                            <el-table-column prop="usedCouponCount" label="用券张数" width="100" align="right" />
+                            <el-table-column prop="paidRedPacketAmount" min-width="112" align="right">
+                                <template slot="header">
+                                    <el-tooltip
+                                        content="订单已支付对应的红包金额"
+                                        placement="top"
+                                        popper-class="dark-tooltip-popper"
+                                    >
+                                        <span class="rank-col-head">已用金额</span>
+                                    </el-tooltip>
+                                </template>
+                                <template slot-scope="scope">
+                                    ¥{{ formatNumber(scope.row.paidRedPacketAmount) }}
+                                </template>
+                            </el-table-column>
+                            <el-table-column prop="unpaidRedPacketAmount" min-width="100" align="right">
+                                <template slot="header">
+                                    <el-tooltip
+                                        content="订单未付、已用券占用的红包金额"
+                                        placement="top"
+                                        popper-class="dark-tooltip-popper"
+                                    >
+                                        <span class="rank-col-head">待付占用</span>
+                                    </el-tooltip>
+                                </template>
+                                <template slot-scope="scope">
+                                    ¥{{ formatNumber(scope.row.unpaidRedPacketAmount) }}
+                                </template>
+                            </el-table-column>
+                        </el-table>
+                    </div>
+                </el-col>
+            </el-row>
+        </div>
+    </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import {
+    getCouponScreenOverview,
+    getCouponAcquireUseTrend,
+    getCustomerList,
+    exportUsageRank as exportUsageRankApi
+} from '@/api/redPacket/screen'
+
+/** 可选租户下拉 + yearMonth;不传 corpsId;选中租户时请求带 tenantId */
+export default {
+    name: 'CouponScreenDashboard',
+
+    components: {
+        MetricCard: {
+            props: ['title', 'value', 'icon', 'color', 'trend', 'prefix', 'suffix', 'formatter', 'loading'],
+            template: `
+                <div class="metric-card" :style="{borderLeftColor: color}">
+                    <div class="metric-icon" :style="{color: color}">
+                        <i :class="icon"></i>
+                    </div>
+                    <div class="metric-content">
+                        <div class="metric-title">{{ title }}</div>
+                        <div class="metric-value">
+                            <span v-if="prefix">{{ prefix }}</span>
+                            <template v-if="loading">
+                                <i class="el-icon-loading"></i> 加载中...
+                            </template>
+                            <template v-else>
+                                {{ formatter ? formatter(value) : value }}
+                                <span v-if="suffix">{{ suffix }}</span>
+                            </template>
+                        </div>
+                        <div class="metric-trend" v-if="trend !== undefined && !loading">
+                            <i :class="trend >= 0 ? 'el-icon-top' : 'el-icon-bottom'"
+                               :style="{color: trend >= 0 ? '#67C23A' : '#F56C6C'}"></i>
+                            <span :style="{color: trend >= 0 ? '#67C23A' : '#F56C6C'}">
+                                {{ Math.abs(trend) }}%
+                            </span>
+                            <span class="trend-text">较上期</span>
+                        </div>
+                    </div>
+                </div>
+            `
+        }
+    },
+
+    data() {
+        return {
+            storeName: '',
+            updateTime: '',
+            statsMonth: null,
+
+            tenantList: [],
+            selectedTenantId: null,
+            pendingTenantId: null,
+            tenantListLoading: false,
+
+            loading: false,
+            refreshing: false,
+            chartLoading: false,
+            trendLoading: false,
+            tableLoading: false,
+
+            kpis: {
+                totalReceived: 0,
+                totalUsed: 0,
+                paidRedPacketAmount: 0,
+                unpaidRedPacketAmount: 0
+            },
+
+            overviewPayload: null,
+            trendSeries: [],
+            usageRank: [],
+
+            amountStatusChart: null,
+            receiveUseChart: null,
+
+            autoRefreshTimer: null,
+            resizeTimer: null,
+
+            exportRankLoading: false
+        }
+    },
+
+    computed: {
+        statsMonthLabel() {
+            if (!this.statsMonth) return '全部'
+            const parts = String(this.statsMonth).split('-')
+            if (parts.length < 2) return this.statsMonth
+            return `${parts[0]}年${Number(parts[1])}月`
+        }
+    },
+
+    mounted() {
+        const q = this.$route.query || {}
+        if (q.storeName) {
+            try {
+                this.storeName = decodeURIComponent(String(q.storeName))
+            } catch (e) {
+                this.storeName = String(q.storeName)
+            }
+        }
+        const ym = q.yearMonth != null ? String(q.yearMonth) : ''
+        if (ym === '' || ym === 'all') {
+            this.statsMonth = null
+        } else if (/^\d{4}-\d{2}$/.test(ym)) {
+            this.statsMonth = ym
+        }
+        if (q.tenantId != null && q.tenantId !== '') {
+            this.pendingTenantId = q.tenantId
+        }
+        this.loadTenantList().then(() => {
+            this.initDashboard()
+            this.startAutoRefresh()
+        })
+        window.addEventListener('resize', this.handleResize)
+    },
+
+    beforeDestroy() {
+        this.stopAutoRefresh()
+        window.removeEventListener('resize', this.handleResize)
+        this.destroyCharts()
+    },
+
+    methods: {
+        screenRequestBody() {
+            const body = {
+                ...(this.statsMonth ? { yearMonth: this.statsMonth } : {})
+            }
+            if (this.selectedTenantId != null && this.selectedTenantId !== '') {
+                body.tenantId = this.selectedTenantId
+            }
+            return body
+        },
+
+        async loadTenantList() {
+            this.tenantListLoading = true
+            try {
+                const res = await getCustomerList()
+                const raw = res && res.data && res.data.data !== undefined ? res.data.data : res && res.data
+                const list = Array.isArray(raw) ? raw : []
+                this.tenantList = list
+                if (this.pendingTenantId != null) {
+                    const want = String(this.pendingTenantId)
+                    const hit = list.find(t => String(t.tenantId) === want)
+                    this.selectedTenantId = hit ? hit.tenantId : null
+                    this.pendingTenantId = null
+                }
+            } catch (e) {
+                console.warn('租户列表加载失败', e)
+                this.tenantList = []
+            } finally {
+                this.tenantListLoading = false
+            }
+        },
+
+        handleTenantChange() {
+            this.initDashboard()
+        },
+
+        /** 接口无数据或失败时的空结构 */
+        emptyOverview() {
+            return {
+                storeName: this.storeName || '',
+                kpis: {
+                    totalReceived: 0,
+                    totalUsed: 0,
+                    paidRedPacketAmount: 0,
+                    unpaidRedPacketAmount: 0
+                },
+                trend: [],
+                usageRank: []
+            }
+        },
+
+        /**
+         * 排行行:已付 / 待付;若仅返回 redPacketAmount 则全部计入已付(兼容旧接口)
+         */
+        normalizeUsageRankList(list) {
+            if (!Array.isArray(list)) return []
+            return list.map(row => {
+                if (!row || typeof row !== 'object') return row
+                const hasSplit =
+                    row.paidRedPacketAmount != null ||
+                    row.usedPaidAmount != null ||
+                    row.paidAmount != null ||
+                    row.unpaidRedPacketAmount != null ||
+                    row.pendingPaymentRedPacketAmount != null ||
+                    row.unpaidAmount != null
+                let paid = 0
+                let unpaid = 0
+                if (hasSplit) {
+                    paid = Number(
+                        row.paidRedPacketAmount != null
+                            ? row.paidRedPacketAmount
+                            : row.usedPaidAmount != null
+                              ? row.usedPaidAmount
+                              : row.paidAmount != null
+                                ? row.paidAmount
+                                : 0
+                    )
+                    unpaid = Number(
+                        row.unpaidRedPacketAmount != null
+                            ? row.unpaidRedPacketAmount
+                            : row.pendingPaymentRedPacketAmount != null
+                              ? row.pendingPaymentRedPacketAmount
+                              : row.unpaidAmount != null
+                                ? row.unpaidAmount
+                                : 0
+                    )
+                } else {
+                    paid = Number(row.redPacketAmount != null ? row.redPacketAmount : 0)
+                    unpaid = 0
+                }
+                const orderCount = Number(
+                    row.orderCount != null ? row.orderCount : row.orderNum != null ? row.orderNum : 0
+                )
+                const usedCouponCount = Number(
+                    row.usedCouponCount != null
+                        ? row.usedCouponCount
+                        : row.usedCount != null
+                          ? row.usedCount
+                          : 0
+                )
+                return {
+                    ...row,
+                    paidRedPacketAmount: paid,
+                    unpaidRedPacketAmount: unpaid,
+                    orderCount,
+                    usedCouponCount
+                }
+            })
+        },
+
+        extractTrendList(payload) {
+            if (!payload) return []
+            if (Array.isArray(payload)) return payload
+            if (typeof payload !== 'object') return []
+            const v =
+                payload.trend ||
+                payload.dailyTrend ||
+                payload.trendList ||
+                payload.dayList ||
+                payload.list
+            return Array.isArray(v) ? v : []
+        },
+
+        normalizeOverview(raw) {
+            if (!raw || typeof raw !== 'object') return this.emptyOverview()
+            const k = raw.kpis || {}
+            const unpaid =
+                k.unpaidRedPacketAmount != null
+                    ? Number(k.unpaidRedPacketAmount)
+                    : Number(k.pendingPaymentRedPacketAmount || k.unpaidAmount || 0)
+            const trend = this.extractTrendList(raw)
+            return {
+                storeName: raw.storeName || this.storeName,
+                kpis: {
+                    totalReceived: Number(k.totalReceived) || 0,
+                    totalUsed: Number(k.totalUsed) || 0,
+                    paidRedPacketAmount: Number(k.paidRedPacketAmount != null ? k.paidRedPacketAmount : k.paidAmount) || 0,
+                    unpaidRedPacketAmount: unpaid
+                },
+                trend,
+                usageRank: this.normalizeUsageRankList(raw.usageRank || raw.userRank || [])
+            }
+        },
+
+        applyOverview(data) {
+            this.overviewPayload = data
+            if (data.storeName) this.storeName = data.storeName
+            this.kpis = data.kpis
+            this.trendSeries = data.trend || []
+            this.usageRank = this.normalizeUsageRankList(data.usageRank || [])
+        },
+
+        async initDashboard() {
+            this.loading = true
+            this.chartLoading = true
+            this.trendLoading = true
+            this.tableLoading = true
+            this.updateTime = this.formatDate(new Date())
+
+            let payload = null
+            try {
+                const res = await getCouponScreenOverview(this.screenRequestBody())
+                const body = res && res.data && res.data.data !== undefined ? res.data.data : res && res.data
+                payload = this.normalizeOverview(body)
+            } catch (e) {
+                console.warn('优惠券大屏总览接口请求失败', e)
+                payload = this.emptyOverview()
+            }
+
+            this.applyOverview(payload)
+
+            this.$nextTick(() => {
+                this.initCharts()
+                this.updateAmountStatusTrendChart(this.trendSeries)
+                this.updateReceiveUseTrendChart(this.trendSeries)
+            })
+
+            try {
+                await this.loadTrendOnly()
+            } catch (e) {
+                console.warn('趋势接口失败,沿用总览中的趋势', e)
+            }
+
+            this.loading = false
+            this.chartLoading = false
+            this.trendLoading = false
+            this.tableLoading = false
+            this.$nextTick(() => {
+                this.resizeCharts()
+            })
+        },
+
+        async loadTrendOnly() {
+            this.trendLoading = true
+            try {
+                const res = await getCouponAcquireUseTrend(this.screenRequestBody())
+                const body = res && res.data && res.data.data !== undefined ? res.data.data : res && res.data
+                const list = this.extractTrendList(body)
+                if (list && list.length) {
+                    this.trendSeries = list
+                    this.updateAmountStatusTrendChart(list)
+                    this.updateReceiveUseTrendChart(list)
+                }
+            } finally {
+                this.trendLoading = false
+                this.$nextTick(() => {
+                    this.resizeCharts()
+                })
+            }
+        },
+
+        initCharts() {
+            this.initAmountStatusChart()
+            this.initReceiveUseChart()
+        },
+
+        moneyTooltipFormatter(params) {
+            if (!params || !params.length) return ''
+            const ax = params[0].axisValue != null ? params[0].axisValue : params[0].name
+            let html = ax + '<br/>'
+            params.forEach(p => {
+                const v = Number(p.value)
+                const txt = isNaN(v)
+                    ? p.value
+                    : '¥' + v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+                html += `${p.marker}${p.seriesName}:${txt}<br/>`
+            })
+            return html
+        },
+
+        initAmountStatusChart() {
+            if (!this.$refs.amountStatusChart) return
+            if (this.amountStatusChart) this.amountStatusChart.dispose()
+            this.amountStatusChart = echarts.init(this.$refs.amountStatusChart)
+            this.amountStatusChart.setOption({
+                backgroundColor: 'transparent',
+                tooltip: {
+                    trigger: 'axis',
+                    backgroundColor: 'rgba(0,0,0,0.85)',
+                    borderColor: '#333',
+                    textStyle: { color: '#fff' },
+                    formatter: p => this.moneyTooltipFormatter(p)
+                },
+                legend: {
+                    data: ['已支付红包', '待支付占用'],
+                    textStyle: { color: '#fff' },
+                    top: 8
+                },
+                grid: { left: '3%', right: '4%', bottom: '3%', top: '18%', containLabel: true },
+                xAxis: {
+                    type: 'category',
+                    data: [],
+                    axisLine: { lineStyle: { color: '#666' } },
+                    axisLabel: { color: '#ccc' }
+                },
+                yAxis: {
+                    type: 'value',
+                    axisLine: { lineStyle: { color: '#666' } },
+                    axisLabel: {
+                        color: '#ccc',
+                        formatter: val => {
+                            const n = Number(val)
+                            if (n >= 10000) return (n / 10000).toFixed(1) + '万'
+                            return n
+                        }
+                    },
+                    splitLine: { lineStyle: { color: '#444', type: 'dashed' } }
+                },
+                series: [
+                    {
+                        name: '已支付红包',
+                        type: 'bar',
+                        stack: 'amt',
+                        data: [],
+                        itemStyle: { color: '#67C23A' }
+                    },
+                    {
+                        name: '待支付占用',
+                        type: 'bar',
+                        stack: 'amt',
+                        data: [],
+                        itemStyle: { color: '#909399' }
+                    }
+                ]
+            })
+        },
+
+        trendItemDayLabel(item) {
+            const s = String(
+                item.date != null
+                    ? item.date
+                    : item.statDate != null
+                      ? item.statDate
+                      : item.day != null
+                        ? item.day
+                        : item.bizDate != null
+                          ? item.bizDate
+                          : ''
+            )
+            if (s.length >= 10) return s.substring(5, 10)
+            return s.length >= 5 ? s.substring(5) : s
+        },
+
+        updateAmountStatusTrendChart(list) {
+            if (!this.amountStatusChart || !list || !list.length) return
+            const dates = list.map(item => this.trendItemDayLabel(item))
+            const paid = list.map(item =>
+                Number(
+                    item.paidRedPacketAmount != null
+                        ? item.paidRedPacketAmount
+                        : item.paidAmount != null
+                            ? item.paidAmount
+                            : 0
+                )
+            )
+            const unpaid = list.map(item =>
+                Number(
+                    item.unpaidRedPacketAmount != null
+                        ? item.unpaidRedPacketAmount
+                        : item.pendingPaymentRedPacketAmount != null
+                            ? item.pendingPaymentRedPacketAmount
+                            : item.unpaidAmount != null
+                                ? item.unpaidAmount
+                                : 0
+                )
+            )
+            this.amountStatusChart.setOption({
+                xAxis: { data: dates },
+                series: [{ data: paid }, { data: unpaid }]
+            })
+        },
+
+        initReceiveUseChart() {
+            if (!this.$refs.receiveUseChart) return
+            if (this.receiveUseChart) this.receiveUseChart.dispose()
+            this.receiveUseChart = echarts.init(this.$refs.receiveUseChart)
+            this.receiveUseChart.setOption({
+                backgroundColor: 'transparent',
+                tooltip: {
+                    trigger: 'axis',
+                    backgroundColor: 'rgba(0,0,0,0.85)',
+                    textStyle: { color: '#fff' }
+                },
+                legend: {
+                    data: ['领取张数', '使用张数'],
+                    textStyle: { color: '#fff' },
+                    top: 8
+                },
+                grid: { left: '3%', right: '4%', bottom: '3%', top: '18%', containLabel: true },
+                xAxis: {
+                    type: 'category',
+                    data: [],
+                    axisLine: { lineStyle: { color: '#666' } },
+                    axisLabel: { color: '#ccc' }
+                },
+                yAxis: {
+                    type: 'value',
+                    axisLine: { lineStyle: { color: '#666' } },
+                    axisLabel: { color: '#ccc' },
+                    splitLine: { lineStyle: { color: '#444', type: 'dashed' } },
+                    minInterval: 1
+                },
+                series: [
+                    {
+                        name: '领取张数',
+                        type: 'line',
+                        smooth: true,
+                        data: [],
+                        itemStyle: { color: '#67C23A' }
+                    },
+                    {
+                        name: '使用张数',
+                        type: 'line',
+                        smooth: true,
+                        data: [],
+                        itemStyle: { color: '#409EFF' }
+                    }
+                ]
+            })
+        },
+
+        updateReceiveUseTrendChart(list) {
+            if (!this.receiveUseChart || !list || !list.length) return
+            const dates = list.map(item => this.trendItemDayLabel(item))
+            const recv = list.map(item => Number(item.receivedCount != null ? item.receivedCount : item.acquire || 0))
+            const used = list.map(item => Number(item.usedCount != null ? item.usedCount : item.use || 0))
+            this.receiveUseChart.setOption({
+                xAxis: { data: dates },
+                series: [{ data: recv }, { data: used }]
+            })
+        },
+
+        async refreshData() {
+            try {
+                this.refreshing = true
+                await this.initDashboard()
+                this.$message.success('数据刷新成功')
+            } catch (error) {
+                console.error('刷新数据失败:', error)
+                this.$message.error('刷新数据失败')
+            } finally {
+                this.refreshing = false
+            }
+        },
+
+        handleStatsMonthChange() {
+            this.initDashboard()
+        },
+
+        startAutoRefresh() {
+            this.autoRefreshTimer = setInterval(() => {
+                this.refreshData()
+            }, 5 * 60 * 1000)
+        },
+
+        stopAutoRefresh() {
+            if (this.autoRefreshTimer) {
+                clearInterval(this.autoRefreshTimer)
+                this.autoRefreshTimer = null
+            }
+        },
+
+        handleResize() {
+            if (this.resizeTimer) clearTimeout(this.resizeTimer)
+            this.resizeTimer = setTimeout(() => this.resizeCharts(), 300)
+        },
+
+        resizeCharts() {[
+                this.amountStatusChart,
+                this.receiveUseChart
+            ].forEach(chart => {
+                if (chart) {
+                    try {
+                        chart.resize()
+                    } catch (e) {
+                        console.error(e)
+                    }
+                }
+            })
+        },
+
+        destroyCharts() {[
+                this.amountStatusChart,
+                this.receiveUseChart
+            ].forEach(chart => {
+                if (chart) {
+                    try {
+                        chart.dispose()
+                    } catch (e) {
+                        console.error(e)
+                    }
+                }
+            })
+        },
+
+        formatCurrency(value) {
+            if (value == null || value === '') return '0.00'
+            const numValue = Number(value)
+            if (isNaN(numValue)) return '0.00'
+            if (numValue >= 100000000) {
+                return (numValue / 100000000).toFixed(2) + '亿'
+            }
+            if (numValue >= 10000) {
+                return (numValue / 10000).toFixed(2) + '万'
+            }
+            return numValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+        },
+
+        formatNumber(value) {
+            if (value == null || value === '') return '0.00'
+            const numValue = Number(value)
+            if (isNaN(numValue)) return '0.00'
+            return numValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+        },
+
+        formatDate(date) {
+            return date.toLocaleString('zh-CN', {
+                year: 'numeric',
+                month: '2-digit',
+                day: '2-digit',
+                hour: '2-digit',
+                minute: '2-digit',
+                second: '2-digit'
+            })
+        },
+
+        async exportUsageRank() {
+            if (this.exportRankLoading) return
+            this.exportRankLoading = true
+            try {
+                const res = await exportUsageRankApi(this.screenRequestBody())
+                const blob = res && res.data
+                if (!(blob instanceof Blob)) {
+                    this.$message.warning('导出失败:未返回文件')
+                    return
+                }
+                if (blob.size === 0) {
+                    this.$message.warning('导出失败:文件为空')
+                    return
+                }
+                if (blob.type && blob.type.indexOf('application/json') !== -1) {
+                    const text = await blob.text()
+                    try {
+                        const j = JSON.parse(text)
+                        this.$message.error(j.msg || j.message || '导出失败')
+                    } catch (e) {
+                        this.$message.error('导出失败')
+                    }
+                    return
+                }
+                let filename = `使用排行_${Date.now()}.xlsx`
+                const cd = res.headers && (res.headers['content-disposition'] || res.headers['Content-Disposition'])
+                if (cd && typeof cd === 'string') {
+                    const m = /filename\*?=(?:UTF-8'')?([^;\n]+)/i.exec(cd)
+                    if (m && m[1]) {
+                        try {
+                            filename = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, ''))
+                        } catch (e) {
+                            filename = m[1].trim().replace(/^["']|["']$/g, '')
+                        }
+                    }
+                }
+                const url = window.URL.createObjectURL(blob)
+                const a = document.createElement('a')
+                a.href = url
+                a.download = filename
+                a.click()
+                window.URL.revokeObjectURL(url)
+                this.$message.success('导出成功')
+            } catch (e) {
+                console.warn('使用排行导出失败', e)
+                this.$message.error('导出请求失败')
+            } finally {
+                this.exportRankLoading = false
+            }
+        }
+    }
+}
+</script>
+
+<style scoped>
+.chart-loading {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    min-height: 200px;
+    color: rgba(255, 255, 255, 0.7);
+}
+
+.chart-loading i {
+    font-size: 24px;
+    margin-bottom: 10px;
+}
+
+.chart-container--echarts {
+    position: relative;
+    min-height: 300px;
+}
+
+.chart-container--echarts .chart-instance {
+    height: 300px;
+    width: 100%;
+}
+
+.chart-loading--overlay {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background: rgba(15, 22, 41, 0.55);
+    z-index: 2;
+    margin-bottom: 0;
+}
+
+.dashboard-container {
+    padding: 20px;
+    background: linear-gradient(145deg, #0f1629 0%, #1a2744 45%, #243352 100%);
+    min-height: 100vh;
+    color: #fff;
+}
+
+.header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 24px;
+    padding-bottom: 16px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.header-title-wrap {
+    min-width: 0;
+}
+
+.header h1 {
+    margin: 0;
+    font-size: 26px;
+    font-weight: 600;
+    letter-spacing: 2px;
+    background: linear-gradient(90deg, #e8f4ff, #7eb6ff);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+}
+
+.header-sub {
+    margin: 8px 0 0;
+    font-size: 14px;
+    color: rgba(255, 255, 255, 0.75);
+}
+
+.header-sub.muted {
+    color: rgba(255, 255, 255, 0.45);
+    font-size: 13px;
+}
+
+.time-info {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+    flex-wrap: wrap;
+}
+
+.month-label {
+    font-size: 13px;
+    color: rgba(255, 255, 255, 0.65);
+}
+
+::v-deep .tenant-select.el-select {
+    min-width: 200px;
+}
+
+::v-deep .dark-month-picker.el-date-editor.el-input {
+    width: 130px;
+}
+
+::v-deep .dark-month-picker .el-input__inner {
+    background-color: rgba(0, 0, 0, 0.25) !important;
+    border-color: rgba(255, 255, 255, 0.15) !important;
+    color: #fff !important;
+}
+
+::v-deep .dark-month-picker .el-input__prefix {
+    color: rgba(255, 255, 255, 0.65);
+}
+
+.core-metrics {
+    margin-bottom: 24px;
+}
+
+.metrics-row {
+    display: flex;
+    flex-wrap: wrap;
+}
+
+.metric-card {
+    background: rgba(255, 255, 255, 0.06);
+    backdrop-filter: blur(10px);
+    border-radius: 10px;
+    padding: 20px 16px;
+    border-left: 4px solid;
+    display: flex;
+    align-items: center;
+    min-height: 112px;
+    margin-bottom: 16px;
+    transition: all 0.25s;
+    box-sizing: border-box;
+}
+
+.metric-card:hover {
+    transform: translateY(-3px);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
+}
+
+.metric-icon {
+    font-size: 32px;
+    margin-right: 14px;
+    opacity: 0.85;
+    flex-shrink: 0;
+}
+
+.metric-content {
+    flex: 1;
+    min-width: 0;
+}
+
+.metric-title {
+    font-size: 13px;
+    color: rgba(255, 255, 255, 0.65);
+    margin-bottom: 6px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.metric-value {
+    font-size: 24px;
+    font-weight: bold;
+    margin-bottom: 4px;
+    line-height: 1.2;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.metric-trend {
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+}
+
+.trend-text {
+    color: rgba(255, 255, 255, 0.55);
+}
+
+.chart-section {
+    margin-top: 8px;
+}
+
+.chart-card {
+    background: rgba(255, 255, 255, 0.05);
+    backdrop-filter: blur(10px);
+    border-radius: 10px;
+    padding: 18px;
+    height: 100%;
+    box-sizing: border-box;
+    border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.chart-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 14px;
+}
+
+.chart-header--col {
+    align-items: flex-start;
+}
+
+.chart-header h3 {
+    margin: 0 0 4px 0;
+    font-size: 15px;
+    font-weight: 600;
+}
+
+.chart-desc {
+    margin: 0;
+    font-size: 12px;
+    color: rgba(255, 255, 255, 0.45);
+    line-height: 1.4;
+}
+
+.rank-col-head {
+    cursor: help;
+    border-bottom: 1px dashed rgba(255, 255, 255, 0.35);
+}
+
+.chart-controls {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.chart-container {
+    height: calc(100% - 36px);
+}
+
+::v-deep .dark-table {
+    background: transparent !important;
+}
+
+::v-deep .dark-table th,
+::v-deep .dark-table tr {
+    background: rgba(255, 255, 255, 0.04) !important;
+    color: #fff !important;
+}
+
+::v-deep .dark-table td {
+    border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
+    color: #e8e8e8 !important;
+}
+
+/* 行悬停:覆盖 Element 默认浅灰高亮,与大屏暗色一致 */
+::v-deep .dark-table .el-table__body tr:hover > td {
+    background-color: rgba(255, 255, 255, 0.07) !important;
+}
+
+::v-deep .dark-table .el-table__body tr.hover-row > td {
+    background-color: rgba(255, 255, 255, 0.07) !important;
+}
+
+::v-deep .dark-table .el-table__fixed-body-wrapper tr:hover > td,
+::v-deep .dark-table .el-table__fixed-body-wrapper tr.hover-row > td {
+    background-color: rgba(255, 255, 255, 0.07) !important;
+}
+
+::v-deep .dark-table::before {
+    background-color: rgba(255, 255, 255, 0.08) !important;
+}
+
+::v-deep .dark-select .el-input__inner {
+    background-color: rgba(0, 0, 0, 0.25) !important;
+    border-color: rgba(255, 255, 255, 0.15) !important;
+    color: #fff !important;
+}
+
+::v-deep .dark-select .el-input__suffix {
+    color: rgba(255, 255, 255, 0.7) !important;
+}
+
+@media (max-width: 768px) {
+    .header {
+        flex-direction: column;
+        align-items: flex-start;
+    }
+
+    .time-info {
+        margin-top: 10px;
+    }
+}
+</style>
+
+<!-- 下拉/日期面板挂在 body,scoped 无法命中,需单独写 -->
+<style>
+.dark-select-popper.el-select-dropdown {
+    background-color: rgba(15, 22, 41, 0.98) !important;
+    border: 1px solid rgba(255, 255, 255, 0.12) !important;
+    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45) !important;
+}
+
+.dark-select-popper .el-scrollbar,
+.dark-select-popper .el-scrollbar__wrap,
+.dark-select-popper .el-select-dropdown__list {
+    background-color: transparent !important;
+}
+
+.dark-select-popper .el-select-dropdown__item {
+    color: #e8e8e8 !important;
+    background-color: transparent !important;
+}
+
+.dark-select-popper .el-select-dropdown__item.hover,
+.dark-select-popper .el-select-dropdown__item:hover {
+    background-color: rgba(255, 255, 255, 0.1) !important;
+}
+
+.dark-select-popper .el-select-dropdown__item.selected {
+    background-color: rgba(64, 158, 255, 0.28) !important;
+    color: #a8d0ff !important;
+}
+
+.dark-select-popper .el-select-dropdown__item.is-disabled {
+    color: rgba(255, 255, 255, 0.35) !important;
+}
+
+.dark-select-popper .el-select-dropdown__empty {
+    color: rgba(255, 255, 255, 0.5) !important;
+    background-color: transparent !important;
+}
+
+/* 月份选择器面板(同 popper-class) */
+.dark-select-popper.el-picker-panel {
+    color: #e8e8e8 !important;
+    background-color: rgba(15, 22, 41, 0.98) !important;
+    border: 1px solid rgba(255, 255, 255, 0.12) !important;
+    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45) !important;
+}
+
+.dark-select-popper .el-picker-panel__body,
+.dark-select-popper .el-date-picker__header,
+.dark-select-popper .el-date-picker__header-label,
+.dark-select-popper .el-picker-panel__icon-btn {
+    color: #e8e8e8 !important;
+}
+
+.dark-select-popper .el-month-table td .cell {
+    color: #e8e8e8 !important;
+}
+
+.dark-select-popper .el-month-table td .cell:hover {
+    color: #79bbff !important;
+}
+
+.dark-select-popper .el-month-table td.current .cell,
+.dark-select-popper .el-month-table td.today .cell {
+    color: #79bbff !important;
+}
+
+/* 表格单元格省略号悬浮、表头问号:与深色大屏一致,避免白底刺眼 */
+.dark-tooltip-popper.el-tooltip__popper {
+    background: rgba(15, 22, 41, 0.98) !important;
+    color: #e8e8e8 !important;
+    border: 1px solid rgba(255, 255, 255, 0.14) !important;
+    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
+    max-width: min(480px, 90vw);
+}
+
+.dark-tooltip-popper.el-tooltip__popper .el-tooltip__popper__inner {
+    color: #e8e8e8 !important;
+    background: transparent !important;
+}
+
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='top'] .popper__arrow,
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='top'] .popper__arrow::after {
+    border-top-color: rgba(15, 22, 41, 0.98) !important;
+}
+
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='bottom'] .popper__arrow,
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='bottom'] .popper__arrow::after {
+    border-bottom-color: rgba(15, 22, 41, 0.98) !important;
+}
+
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='left'] .popper__arrow,
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='left'] .popper__arrow::after {
+    border-left-color: rgba(15, 22, 41, 0.98) !important;
+}
+
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='right'] .popper__arrow,
+.dark-tooltip-popper.el-tooltip__popper[x-placement^='right'] .popper__arrow::after {
+    border-right-color: rgba(15, 22, 41, 0.98) !important;
+}
+</style>