Files
medical-mall/pages/mall/admin/finance/transaction-statistics/index.uvue
2026-03-20 15:24:59 +08:00

442 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="finance-transaction-stats">
<!-- 头部筛选 -->
<view class="header-filters">
<view class="date-tabs">
<text
v-for="(item, index) in dateOptions"
:key="index"
class="date-tab-item"
:class="{ active: activeDateTab === index }"
@click="handleDateTabChange(index)"
>{{ item }}</text>
</view>
<view class="date-picker-wrap">
<text class="calendar-icon">D</text>
<text class="date-range-text">{{ displayDateRange }}</text>
</view>
</view>
<!-- 交易概况区块 -->
<view class="overview-card">
<view class="overview-header">
<view class="header-left">
<text class="section-title">交易概况</text>
<text class="info-tag">?</text>
</view>
<view class="header-right">
<button class="btn-query" @click="loadData">刷新数据</button>
<button class="btn-export">导出报表</button>
</view>
</view>
<!-- 指标网格 -->
<view class="overview-grid">
<!-- 第一行 -->
<view class="grid-row">
<view class="overview-item">
<view class="icon-box blue"><text class="icon">🕒</text></view>
<view class="item-info">
<text class="item-label">营业额</text>
<text class="item-value">¥{{ stats.revenue }}</text>
<view class="trend-row">
<text class="trend-label">昨日对比:</text>
<text class="trend-value">{{ stats.revenueTrend }}</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box green"><text class="icon">¥</text></view>
<view class="item-info">
<text class="item-label">商品支付金额</text>
<text class="item-value">¥{{ stats.payAmount }}</text>
<view class="trend-row">
<text class="trend-label">交易笔数:</text>
<text class="trend-value">{{ stats.orderCount }}</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box orange"><text class="icon">🔒</text></view>
<view class="item-info">
<text class="item-label">购买会员金额</text>
<text class="item-value">¥{{ stats.memberAmount }}</text>
<view class="trend-row">
<text class="trend-label">占比:</text>
<text class="trend-value">-</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box purple"><text class="icon">💰</text></view>
<view class="item-info">
<text class="item-label">充值金额</text>
<text class="item-value">¥{{ stats.rechargeAmount }}</text>
<view class="trend-row">
<text class="trend-label">笔数:</text>
<text class="trend-value">{{ stats.rechargeCount }}</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box cyan"><text class="icon">🛒</text></view>
<view class="item-info">
<text class="item-label">线下收银金额</text>
<text class="item-value">¥{{ stats.offlineAmount }}</text>
<view class="trend-row">
<text class="trend-label">占比:</text>
<text class="trend-value">-</text>
</view>
</view>
</view>
</view>
<!-- 第二行 -->
<view class="grid-row second">
<view class="overview-item">
<view class="icon-box light-green"><text class="icon">↘</text></view>
<view class="item-info">
<text class="item-label">支出金额 (提现)</text>
<text class="item-value">¥{{ stats.expenditure }}</text>
<view class="trend-row">
<text class="trend-label">笔数:</text>
<text class="trend-value">{{ stats.extractCount }}</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box gold"><text class="icon">💳</text></view>
<view class="item-info">
<text class="item-label">全站余额存量</text>
<text class="item-value">¥{{ stats.balancePay }}</text>
<view class="trend-row">
<text class="trend-label">用户总数:</text>
<text class="trend-value">-</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box red-purple"><text class="icon"></text></view>
<view class="item-info">
<text class="item-label">佣金总存量</text>
<text class="item-value">¥{{ stats.commissionPay }}</text>
<view class="trend-row">
<text class="trend-label">待结算:</text>
<text class="trend-value">-</text>
</view>
</view>
</view>
<view class="overview-item">
<view class="icon-box blue-gray"><text class="icon">📦</text></view>
<view class="item-info">
<text class="item-label">商品退款金额</text>
<text class="item-value">¥{{ stats.refundAmount }}</text>
<view class="trend-row">
<text class="trend-label">退款率:</text>
<text class="trend-value">-</text>
</view>
</view>
</view>
<view class="overview-item transparent"></view>
</view>
</view>
<!-- 概况图表区 -->
<view class="overview-chart-section">
<view class="overview-chart-box">
<view v-if="loading" class="chart-loading"><text>统计加载中...</text></view>
<EChartsView v-else-if="overviewTrendOption != null" :option="overviewTrendOption" class="main-trend-chart" />
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, reactive, computed } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchFinanceOverview, fetchFinanceBillSummary } from '@/services/admin/financeService.uts'
import { fetchOrderStats } from '@/services/orderService.uts'
import AkReq from '@/uni_modules/ak-req/ak-req.uts'
const dateOptions = ['最近30天', '最近7天', '本月', '本年']
const activeDateTab = ref(0)
const loading = ref(false)
const stats = reactive({
revenue: '0.00',
payAmount: '0.00',
orderCount: '0',
memberAmount: '0.00',
rechargeAmount: '0.00',
rechargeCount: '0',
offlineAmount: '0.00',
expenditure: '0.00',
extractCount: '0',
balancePay: '0.00',
commissionPay: '0.00',
refundAmount: '0.00',
revenueTrend: '-'
})
const overviewTrendOption = ref<any>(null)
const displayDateRange = computed(() : string => {
const now = new Date()
const start = getStartTime()
return `${start.substring(0, 10)} - ${now.toISOString().substring(0, 10)}`
})
function getStartTime() : string {
const now = Date.now()
let days = 30
if (activeDateTab.value == 1) days = 7
if (activeDateTab.value == 2) days = 30 // 简化处理
if (activeDateTab.value == 3) days = 365
return new Date(now - days * 24 * 60 * 60 * 1000).toISOString()
}
async function loadData() {
loading.value = true
const endTime = new Date().toISOString()
const startTime = getStartTime()
// 各区块独立 try-catch单个接口失败不影响其他数据展示
// 1. 获取财务概况
try {
const finRes = await fetchFinanceOverview(startTime, endTime)
if (finRes != null) {
stats.rechargeAmount = finRes.recharge_amount.toFixed(2)
stats.rechargeCount = String(finRes.recharge_count)
stats.expenditure = finRes.extract_amount.toFixed(2)
stats.extractCount = String(finRes.extract_count)
stats.balancePay = finRes.total_user_balance.toFixed(2)
stats.commissionPay = finRes.total_user_brokerage.toFixed(2)
}
} catch (e) {
console.error('[finance-stats] 财务概况加载失败', e)
}
// 2. 获取订单统计(与订单统计页共用同一 RPC此处独立请求
try {
const orderRes = await fetchOrderStats(startTime, endTime)
if (orderRes != null) {
stats.revenue = orderRes.total_amount.toFixed(2)
stats.payAmount = orderRes.total_amount.toFixed(2)
stats.orderCount = String(orderRes.order_count)
stats.refundAmount = orderRes.refund_amount.toFixed(2)
}
} catch (e) {
console.error('[finance-stats] 订单统计加载失败', e)
}
// 3. 获取趋势数据驱动图表
try {
const trendRes = await fetchFinanceBillSummary(startTime, endTime, 'day')
updateChart(trendRes)
} catch (e) {
console.error('[finance-stats] 趋势图加载失败', e)
}
loading.value = false
}
function handleDateTabChange(index : number) {
activeDateTab.value = index
loadData()
}
onMounted(() => {
if (AkReq.getToken() == null || AkReq.getToken() === '') {
return
}
loadData()
})
function toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item: any) : any => toPlainObject(item))
}
const plain: any = {}
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object') {
plain[key] = toPlainObject(value)
} else {
plain[key] = value
}
}
return plain
}
function updateChart(data : any[]) {
const dates = data.map(item => item.date_group.substring(5))
const incomes = data.map(item => item.income)
const expenses = data.map(item => item.expense)
const option = {
grid: { left: '3%', right: '4%', bottom: '10%', top: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
legend: { data: ['收入', '支出'], bottom: 0 },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: '#f0f0f0' } },
axisLabel: { color: '#999', fontSize: 10 }
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f5f5f5' } },
axisLabel: { color: '#999' }
},
series: [
{
name: '收入',
type: 'line',
smooth: true,
data: incomes,
itemStyle: { color: '#1890ff' },
areaStyle: { color: 'rgba(24, 144, 255, 0.1)' }
},
{
name: '支出',
type: 'line',
smooth: true,
data: expenses,
itemStyle: { color: '#fa8c16' },
areaStyle: { color: 'rgba(250, 140, 22, 0.1)' }
}
]
}
overviewTrendOption.value = toPlainObject(option)
}
</script>
<style scoped lang="scss">
.finance-transaction-stats {
padding: 0;
background-color: transparent;
min-height: auto;
}
.header-filters {
background: #fff;
padding: 12px 20px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
}
.date-tabs {
display: flex;
flex-direction: row;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 20px;
}
.date-tab-item {
padding: 4px 15px;
font-size: 14px;
color: #666;
border-right: 1px solid #d9d9d9;
cursor: pointer;
&:last-child { border-right: none; }
&.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
}
.date-picker-wrap {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px 12px;
border: 1px solid #dcdfe6;
border-radius: 2px;
}
.calendar-icon { margin-right: 8px; font-size: 14px; }
.date-range-text { font-size: 14px; color: #333; }
.overview-card {
background: #fff;
border-radius: 4px;
padding: 20px;
}
.overview-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-left { display: flex; flex-direction: row; align-items: center; }
.section-title { font-size: 16px; font-weight: bold; color: #333; }
.info-tag {
width: 16px; height: 16px; border-radius: 50%;
background: #eee; color: #999; font-size: 11px;
display: flex; align-items: center; justify-content: center; margin-left: 8px;
}
.header-right { display: flex; flex-direction: row; gap: 12px; }
.btn-query, .btn-export {
padding: 0 16px; height: 32px; line-height: 32px;
font-size: 13px; border-radius: 4px; cursor: pointer;
}
.btn-query { background-color: #1890ff; color: #fff; border: none; }
.btn-export { background: #fff; color: #1890ff; border: 1px solid #1890ff; }
.overview-grid {
display: flex;
flex-direction: column;
gap: 30px;
padding-bottom: 30px;
border-bottom: 1px dashed #f0f0f0;
}
.grid-row { display: flex; flex-direction: row; justify-content: space-between; }
.overview-item { flex: 1; display: flex; flex-direction: row; align-items: center; }
.overview-item.transparent { visibility: hidden; }
.icon-box {
width: 44px; height: 44px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; margin-right: 12px;
text { color: #fff; font-size: 20px; }
}
.icon-box.blue { background-color: #2f54eb; }
.icon-box.green { background-color: #52c41a; }
.icon-box.orange { background-color: #fa8c16; }
.icon-box.purple { background-color: #722ed1; }
.icon-box.cyan { background-color: #13c2c2; }
.icon-box.light-green { background-color: #a0d911; }
.icon-box.gold { background-color: #faad14; }
.icon-box.red-purple { background-color: #eb2f96; }
.icon-box.blue-gray { background-color: #4096ff; }
.item-info { display: flex; flex-direction: column; }
.item-label { font-size: 13px; color: #999; margin-bottom: 4px; }
.item-value { font-size: 22px; font-weight: bold; color: #333; margin-bottom: 4px; }
.trend-row { display: flex; flex-direction: row; font-size: 12px; color: #999; }
.trend-value { margin-left: 4px; }
.overview-chart-section { padding-top: 24px; }
.overview-chart-box { width: 100%; height: 350px; }
.main-trend-chart { width: 100%; height: 100%; }
.chart-loading { height: 100%; display: flex; align-items: center; justify-content: center; color: #999; }
</style>