|
|
@@ -1,1001 +1,386 @@
|
|
|
<template>
|
|
|
-
|
|
|
<div class="order-value-view">
|
|
|
-
|
|
|
- <!-- 头部区域 -->
|
|
|
-
|
|
|
<header class="page-header">
|
|
|
-
|
|
|
<h1 class="page-title">订单价值</h1>
|
|
|
-
|
|
|
<div class="header-controls">
|
|
|
-
|
|
|
<span class="control-label">时光回溯控制器</span>
|
|
|
-
|
|
|
+ <el-date-picker
|
|
|
+ v-model="selectedDate"
|
|
|
+ class="date-picker"
|
|
|
+ type="date"
|
|
|
+ value-format="yyyy-MM-dd"
|
|
|
+ :clearable="false"
|
|
|
+ :editable="false"
|
|
|
+ :picker-options="pickerOptions"
|
|
|
+ placeholder="选择上传数据日期"
|
|
|
+ @change="handleDateChange"
|
|
|
+ />
|
|
|
<div class="control-item-group">
|
|
|
-
|
|
|
- <button :class="{'time-tab': true, 'active': activeTab === '7d'}" @click="selectDateRange('7d')">最近7天</button>
|
|
|
-
|
|
|
- <button :class="{'time-tab': true, 'active': activeTab === 'tm'}" @click="selectDateRange('tm')">本月</button>
|
|
|
-
|
|
|
- <button :class="{'time-tab': true, 'active': activeTab === 'lm'}" @click="selectDateRange('lm')">上月</button>
|
|
|
-
|
|
|
- <button :class="{'time-tab': true, 'active': activeTab === 'all'}" @click="selectDateRange('all')">全量数据</button>
|
|
|
-
|
|
|
- </div>
|
|
|
-
|
|
|
- <button class="upload-btn" :disabled="uploadingOrder" @click="triggerOrderUpload">{{ uploadingOrder ? '上传中...' : '上传CSV导入' }}</button>
|
|
|
- <input ref="orderUploadInput" type="file" accept=".csv" multiple style="display: none;" @change="handleOrderUploadChange">
|
|
|
-
|
|
|
- </div>
|
|
|
-
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'day' }]" @click="selectDateRange('day')">当天</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === '7d' }]" @click="selectDateRange('7d')">最近7天</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'tm' }]" @click="selectDateRange('tm')">本月</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'lm' }]" @click="selectDateRange('lm')">上月</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'all' }]" @click="selectDateRange('all')">全量数据</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</header>
|
|
|
|
|
|
-
|
|
|
-
|
|
|
- <!-- ✨【修改点1:KPI卡片的值现在是动态的】✨ -->
|
|
|
-
|
|
|
<section class="kpi-cards-grid">
|
|
|
-
|
|
|
- <KpiCard title="总交易额 (GMV)" :value="kpiData.gmv" :trend="kpiData.gmvTrend" :trend-color="getTrendColor(kpiData.gmvTrend)" icon="📈" />
|
|
|
-
|
|
|
- <KpiCard title="P80 订单贡献比" :value="kpiData.p80Contribution" :trend="kpiData.p80Trend" :trend-color="getTrendColor(kpiData.p80Trend)" icon="🏆" />
|
|
|
-
|
|
|
- <KpiCard title="Top 5 商品贡献比" :value="kpiData.top5Contribution" :trend="kpiData.top5Trend" :trend-color="getTrendColor(kpiData.top5Trend)" icon="🔥" />
|
|
|
-
|
|
|
- <KpiCard title="平均支付响应" :value="kpiData.averagePaymentTime" :trend="kpiData.avgTimeTrend" :trend-color="getTrendColor(kpiData.avgTimeTrend)" icon="⏱" />
|
|
|
-
|
|
|
+ <OrderLoadingPanel
|
|
|
+ v-if="dashboardLoading"
|
|
|
+ compact
|
|
|
+ title="正在加载核心指标"
|
|
|
+ detail="正在读取 GMV、贡献比与支付响应"
|
|
|
+ />
|
|
|
+ <KpiCard title="总交易额 (GMV)" :value="kpiData.gmv" :trend="kpiData.gmvTrend" :trend-color="getTrendColor(kpiData.gmvTrend)" icon="$" />
|
|
|
+ <KpiCard title="P80 订单贡献比" :value="kpiData.p80Contribution" :trend="kpiData.p80Trend" :trend-color="getTrendColor(kpiData.p80Trend)" icon="P" />
|
|
|
+ <KpiCard title="Top 5 商品贡献比" :value="kpiData.top5Contribution" :trend="kpiData.top5Trend" :trend-color="getTrendColor(kpiData.top5Trend)" icon="5" />
|
|
|
+ <KpiCard title="平均支付响应" :value="kpiData.averagePaymentTime" :trend="kpiData.avgTimeTrend" :trend-color="getTrendColor(kpiData.avgTimeTrend)" icon="T" />
|
|
|
</section>
|
|
|
|
|
|
-
|
|
|
-
|
|
|
- <!-- 图表区域 -->
|
|
|
-
|
|
|
<section class="charts-area">
|
|
|
-
|
|
|
- <!-- 支付决策漏斗图 -->
|
|
|
-
|
|
|
<div class="chart-wrapper funnel-chart-wrapper">
|
|
|
-
|
|
|
- <FunnelChart :date-range="currentDateRange" />
|
|
|
-
|
|
|
+ <FunnelChart :date-range="currentDateRange" />
|
|
|
</div>
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- <!-- 明星商品价值环图 (Top 5) -->
|
|
|
-
|
|
|
<div class="chart-wrapper">
|
|
|
-
|
|
|
- <Top5PieChart :date-range="currentDateRange" />
|
|
|
-
|
|
|
+ <Top5PieChart :date-range="currentDateRange" />
|
|
|
</div>
|
|
|
-
|
|
|
</section>
|
|
|
|
|
|
-
|
|
|
-
|
|
|
- <!-- 订单价值漏损分析 (退款) - 铺满整排 -->
|
|
|
-
|
|
|
<section class="leakage-section">
|
|
|
-
|
|
|
<div class="chart-wrapper leakage-card-wrapper full-width">
|
|
|
-
|
|
|
<LeakageCard :date-range="currentDateRange" />
|
|
|
-
|
|
|
</div>
|
|
|
-
|
|
|
</section>
|
|
|
-
|
|
|
</div>
|
|
|
-
|
|
|
</template>
|
|
|
|
|
|
-
|
|
|
-
|
|
|
<script>
|
|
|
-import KpiCard from './KpiCard/index.vue';
|
|
|
-import FunnelChart from './FunnelChart/index.vue';
|
|
|
-import Top5PieChart from './Top5PieChart/index.vue';
|
|
|
-import LeakageCard from './LeakageCard/index.vue';
|
|
|
+import KpiCard from './KpiCard/index.vue'
|
|
|
+import FunnelChart from './FunnelChart/index.vue'
|
|
|
+import Top5PieChart from './Top5PieChart/index.vue'
|
|
|
+import LeakageCard from './LeakageCard/index.vue'
|
|
|
+import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
|
|
|
import {
|
|
|
getOrderAveragePaymentTime,
|
|
|
getOrderGmv,
|
|
|
getOrderMaxDate,
|
|
|
getOrderRBig,
|
|
|
- getOrderTop5Percentage,
|
|
|
- uploadOrderValueFiles
|
|
|
-} from '@/api/order';
|
|
|
-
|
|
|
-
|
|
|
+ getOrderTop5Percentage
|
|
|
+} from '@/api/order'
|
|
|
|
|
|
export default {
|
|
|
-
|
|
|
name: 'OrderValue',
|
|
|
-
|
|
|
components: {
|
|
|
-
|
|
|
KpiCard,
|
|
|
-
|
|
|
FunnelChart,
|
|
|
-
|
|
|
Top5PieChart,
|
|
|
-
|
|
|
- LeakageCard
|
|
|
-
|
|
|
+ LeakageCard,
|
|
|
+ OrderLoadingPanel
|
|
|
},
|
|
|
-
|
|
|
data() {
|
|
|
-
|
|
|
return {
|
|
|
-
|
|
|
- selectedDate: new Date().toISOString().split('T')[0],
|
|
|
-
|
|
|
+ selectedDate: '',
|
|
|
activeTab: '7d',
|
|
|
-
|
|
|
maxDate: '',
|
|
|
-
|
|
|
currentDateRange: { start: '', end: '' },
|
|
|
-
|
|
|
- uploadingOrder: false,
|
|
|
-
|
|
|
+ dashboardLoading: true,
|
|
|
+ pickerOptions: {
|
|
|
+ disabledDate: time => {
|
|
|
+ if (!this.maxDate) return false
|
|
|
+ return time.getTime() > new Date(`${this.maxDate}T23:59:59`).getTime()
|
|
|
+ }
|
|
|
+ },
|
|
|
kpiData: {
|
|
|
-
|
|
|
- gmv: '?0',
|
|
|
-
|
|
|
+ gmv: '¥0',
|
|
|
gmvTrend: '+0%',
|
|
|
-
|
|
|
p80Contribution: '0%',
|
|
|
-
|
|
|
p80Trend: '+0%',
|
|
|
-
|
|
|
top5Contribution: '0%',
|
|
|
-
|
|
|
top5Trend: '+0%',
|
|
|
-
|
|
|
averagePaymentTime: '00:00 秒',
|
|
|
-
|
|
|
avgTimeTrend: '+0%'
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- };
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- watch: {
|
|
|
-
|
|
|
- selectedDate(newVal) {
|
|
|
-
|
|
|
- if (this.activeTab === '7d') {
|
|
|
-
|
|
|
- const startDate = this.formatYmd(this.addDays(newVal, -6));
|
|
|
-
|
|
|
- const endDate = this.formatYmd(newVal);
|
|
|
-
|
|
|
- this.currentDateRange = { start: startDate, end: endDate };
|
|
|
-
|
|
|
- this.fetchAllApiData({ start: startDate, end: endDate });
|
|
|
-
|
|
|
- } else {
|
|
|
-
|
|
|
- this.selectDateRange(this.activeTab);
|
|
|
-
|
|
|
}
|
|
|
-
|
|
|
}
|
|
|
-
|
|
|
},
|
|
|
-
|
|
|
mounted() {
|
|
|
-
|
|
|
- this.initDashboard();
|
|
|
-
|
|
|
+ this.initDashboard()
|
|
|
},
|
|
|
-
|
|
|
methods: {
|
|
|
-
|
|
|
- triggerOrderUpload() {
|
|
|
-
|
|
|
- if (this.uploadingOrder) {
|
|
|
-
|
|
|
- return;
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- if (this.$refs.orderUploadInput) {
|
|
|
-
|
|
|
- this.$refs.orderUploadInput.click();
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- async handleOrderUploadChange(event) {
|
|
|
-
|
|
|
- const files = Array.from((event && event.target && event.target.files) || []);
|
|
|
-
|
|
|
- if (!files.length) {
|
|
|
-
|
|
|
- return;
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- const invalid = files.find(file => !String(file.name || '').toLowerCase().endsWith('.csv'));
|
|
|
-
|
|
|
- if (invalid) {
|
|
|
-
|
|
|
- this.$modal.msgError('仅支持上传CSV文件');
|
|
|
-
|
|
|
- event.target.value = '';
|
|
|
-
|
|
|
- return;
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- await this.uploadOrderFiles(files);
|
|
|
-
|
|
|
- event.target.value = '';
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- async uploadOrderFiles(files) {
|
|
|
-
|
|
|
- this.uploadingOrder = true;
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- const formData = new FormData();
|
|
|
-
|
|
|
- files.forEach(file => formData.append('files', file));
|
|
|
-
|
|
|
- const data = await uploadOrderValueFiles(formData);
|
|
|
-
|
|
|
- if (data && data.success) {
|
|
|
-
|
|
|
- this.$modal.msgSuccess(data.message || '上传并导入成功');
|
|
|
-
|
|
|
- await this.initDashboard();
|
|
|
-
|
|
|
- return;
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
- this.$modal.msgError((data && data.message) || '上传导入失败');
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
-
|
|
|
- const msg = error && error.response && error.response.data && error.response.data.message
|
|
|
-
|
|
|
- ? error.response.data.message
|
|
|
-
|
|
|
- : (error && error.message ? error.message : '上传导入失败');
|
|
|
-
|
|
|
- this.$modal.msgError(msg);
|
|
|
-
|
|
|
- } finally {
|
|
|
-
|
|
|
- this.uploadingOrder = false;
|
|
|
-
|
|
|
+ handleDateChange() {
|
|
|
+ if (!this.selectedDate) {
|
|
|
+ this.selectedDate = this.maxDate
|
|
|
}
|
|
|
-
|
|
|
+ this.selectDateRange(this.activeTab)
|
|
|
},
|
|
|
-
|
|
|
pad2(value) {
|
|
|
-
|
|
|
- return String(value).padStart(2, '0');
|
|
|
-
|
|
|
+ return String(value).padStart(2, '0')
|
|
|
},
|
|
|
-
|
|
|
toDate(value) {
|
|
|
-
|
|
|
- if (value instanceof Date) return new Date(value.getTime());
|
|
|
-
|
|
|
- if (!value) return new Date();
|
|
|
-
|
|
|
- return new Date(`${value}T00:00:00`);
|
|
|
-
|
|
|
+ if (value instanceof Date) return new Date(value.getTime())
|
|
|
+ if (!value) return new Date()
|
|
|
+ return new Date(`${value}T00:00:00`)
|
|
|
},
|
|
|
-
|
|
|
formatYmd(value) {
|
|
|
-
|
|
|
- const date = this.toDate(value);
|
|
|
-
|
|
|
- return `${date.getFullYear()}-${this.pad2(date.getMonth() + 1)}-${this.pad2(date.getDate())}`;
|
|
|
-
|
|
|
+ const date = this.toDate(value)
|
|
|
+ return `${date.getFullYear()}-${this.pad2(date.getMonth() + 1)}-${this.pad2(date.getDate())}`
|
|
|
},
|
|
|
-
|
|
|
addDays(value, days) {
|
|
|
-
|
|
|
- const date = this.toDate(value);
|
|
|
-
|
|
|
- date.setDate(date.getDate() + days);
|
|
|
-
|
|
|
- return date;
|
|
|
-
|
|
|
+ const date = this.toDate(value)
|
|
|
+ date.setDate(date.getDate() + days)
|
|
|
+ return date
|
|
|
},
|
|
|
-
|
|
|
addMonths(value, months) {
|
|
|
-
|
|
|
- const date = this.toDate(value);
|
|
|
-
|
|
|
- date.setMonth(date.getMonth() + months);
|
|
|
-
|
|
|
- return date;
|
|
|
-
|
|
|
+ const date = this.toDate(value)
|
|
|
+ date.setMonth(date.getMonth() + months)
|
|
|
+ return date
|
|
|
},
|
|
|
-
|
|
|
startOfMonth(value) {
|
|
|
-
|
|
|
- const date = this.toDate(value);
|
|
|
-
|
|
|
- return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
|
-
|
|
|
+ const date = this.toDate(value)
|
|
|
+ return new Date(date.getFullYear(), date.getMonth(), 1)
|
|
|
},
|
|
|
-
|
|
|
endOfMonth(value) {
|
|
|
-
|
|
|
- const date = this.toDate(value);
|
|
|
-
|
|
|
- return new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
|
|
-
|
|
|
+ const date = this.toDate(value)
|
|
|
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
|
|
},
|
|
|
-
|
|
|
- async fetchAllApiData(customRange = null) {
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- let startDate;
|
|
|
-
|
|
|
- let endDate;
|
|
|
-
|
|
|
- if (customRange && customRange.start && customRange.end) {
|
|
|
-
|
|
|
- startDate = customRange.start;
|
|
|
-
|
|
|
- endDate = customRange.end;
|
|
|
-
|
|
|
- } else {
|
|
|
-
|
|
|
- endDate = this.selectedDate;
|
|
|
-
|
|
|
- startDate = this.formatYmd(this.addDays(endDate, -6));
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
|
|
|
-
|
|
|
- getOrderGmv({ startDate, endDate }),
|
|
|
-
|
|
|
- getOrderRBig({ startDate, endDate }),
|
|
|
-
|
|
|
- getOrderTop5Percentage({ startDate, endDate }),
|
|
|
-
|
|
|
- getOrderAveragePaymentTime({ startDate, endDate })
|
|
|
-
|
|
|
- ]);
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const gmvValue = gmvRes || 0;
|
|
|
-
|
|
|
- this.kpiData.gmv = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(gmvValue);
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const p80Value = p80Res?.rBigRatio || 0;
|
|
|
-
|
|
|
- this.kpiData.p80Contribution = `${Math.round(p80Value)}%`;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const top5Value = top5Res?.data?.top5Percentage || 0;
|
|
|
-
|
|
|
- this.kpiData.top5Contribution = `${Math.round(top5Value)}%`;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const avgSeconds = avgTimeRes?.averagePaymentSeconds || 0;
|
|
|
-
|
|
|
- const minutes = Math.floor(avgSeconds / 60);
|
|
|
-
|
|
|
- const seconds = Math.round(avgSeconds % 60);
|
|
|
-
|
|
|
- this.kpiData.averagePaymentTime = `${this.pad2(minutes)}:${this.pad2(seconds)} 秒`;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- if (this.activeTab !== 'tm') {
|
|
|
-
|
|
|
- this.kpiData.gmvTrend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.p80Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.top5Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.avgTimeTrend = '+0%';
|
|
|
-
|
|
|
+ getCurrentRange(type = this.activeTab) {
|
|
|
+ const baseDate = this.toDate(this.selectedDate || this.maxDate || new Date())
|
|
|
+ if (type === 'day') {
|
|
|
+ const day = this.formatYmd(baseDate)
|
|
|
+ return { start: day, end: day }
|
|
|
+ }
|
|
|
+ if (type === '7d') {
|
|
|
+ return {
|
|
|
+ start: this.formatYmd(this.addDays(baseDate, -6)),
|
|
|
+ end: this.formatYmd(baseDate)
|
|
|
}
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
-
|
|
|
- console.error('获取订单价值数据失败:', error);
|
|
|
-
|
|
|
}
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- calculateTrend(currentValue, previousValue) {
|
|
|
-
|
|
|
- if (previousValue === 0) return '+0%';
|
|
|
-
|
|
|
- const change = ((currentValue - previousValue) / previousValue) * 100;
|
|
|
-
|
|
|
- const sign = change >= 0 ? '+' : '';
|
|
|
-
|
|
|
- return `${sign}${change.toFixed(1)}%`;
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- parseTimeString(timeString) {
|
|
|
-
|
|
|
- if (!timeString) return 0;
|
|
|
-
|
|
|
- const timeMatch = timeString.match(/(\d{2}):(\d{2})/);
|
|
|
-
|
|
|
- if (timeMatch) {
|
|
|
-
|
|
|
- const minutes = parseInt(timeMatch[1], 10);
|
|
|
-
|
|
|
- const seconds = parseInt(timeMatch[2], 10);
|
|
|
-
|
|
|
- return minutes * 60 + seconds;
|
|
|
-
|
|
|
+ if (type === 'tm') {
|
|
|
+ return {
|
|
|
+ start: this.formatYmd(this.startOfMonth(baseDate)),
|
|
|
+ end: this.formatYmd(this.endOfMonth(baseDate))
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
- return 0;
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- getTrendColor(trend) {
|
|
|
-
|
|
|
- if (!trend) return 'green';
|
|
|
-
|
|
|
- const value = parseFloat(trend);
|
|
|
-
|
|
|
- return value >= 0 ? 'green' : 'red';
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- async fetchPreviousMonthData(currentStart) {
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- const currentStartDate = this.toDate(currentStart);
|
|
|
-
|
|
|
- const previousMonthBase = this.addMonths(currentStartDate, -1);
|
|
|
-
|
|
|
- const previousMonthStart = this.formatYmd(this.startOfMonth(previousMonthBase));
|
|
|
-
|
|
|
- const previousMonthEnd = this.formatYmd(this.endOfMonth(previousMonthBase));
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
|
|
|
-
|
|
|
- getOrderGmv({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
-
|
|
|
- getOrderRBig({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
-
|
|
|
- getOrderTop5Percentage({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
-
|
|
|
- getOrderAveragePaymentTime({ startDate: previousMonthStart, endDate: previousMonthEnd })
|
|
|
-
|
|
|
- ]);
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
+ if (type === 'lm') {
|
|
|
+ const lastMonth = this.addMonths(baseDate, -1)
|
|
|
return {
|
|
|
-
|
|
|
- gmv: gmvRes || 0,
|
|
|
-
|
|
|
- p80: p80Res?.rBigRatio || 0,
|
|
|
-
|
|
|
- top5: top5Res?.data?.top5Percentage || 0,
|
|
|
-
|
|
|
- avgTime: avgTimeRes?.averagePaymentSeconds || 0
|
|
|
-
|
|
|
- };
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
-
|
|
|
- console.error('获取订单价值数据失败:', error);
|
|
|
-
|
|
|
- return null;
|
|
|
-
|
|
|
+ start: this.formatYmd(this.startOfMonth(lastMonth)),
|
|
|
+ end: this.formatYmd(this.endOfMonth(lastMonth))
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
- },
|
|
|
-
|
|
|
- async initDashboard() {
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- const res = await getOrderMaxDate();
|
|
|
-
|
|
|
- this.maxDate = res;
|
|
|
-
|
|
|
- this.selectedDate = res;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const startDate = this.formatYmd(this.addDays(res, -6));
|
|
|
-
|
|
|
- this.currentDateRange = { start: startDate, end: res };
|
|
|
-
|
|
|
- await this.fetchAllApiData();
|
|
|
-
|
|
|
- this.kpiData.gmvTrend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.p80Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.top5Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.avgTimeTrend = '+0%';
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
-
|
|
|
- console.error('获取最大日期失败');
|
|
|
-
|
|
|
- const today = this.formatYmd(new Date());
|
|
|
-
|
|
|
- this.maxDate = today;
|
|
|
-
|
|
|
- this.selectedDate = today;
|
|
|
-
|
|
|
- const startDate = this.formatYmd(this.addDays(today, -6));
|
|
|
-
|
|
|
- this.currentDateRange = { start: startDate, end: today };
|
|
|
-
|
|
|
- await this.fetchAllApiData();
|
|
|
-
|
|
|
- this.kpiData.gmvTrend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.p80Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.top5Trend = '+0%';
|
|
|
-
|
|
|
- this.kpiData.avgTimeTrend = '+0%';
|
|
|
-
|
|
|
+ return {
|
|
|
+ start: '2022-01-01',
|
|
|
+ end: this.maxDate || this.formatYmd(baseDate)
|
|
|
}
|
|
|
-
|
|
|
},
|
|
|
+ async fetchAllApiData(customRange = null) {
|
|
|
+ this.dashboardLoading = true
|
|
|
+ try {
|
|
|
+ const { start, end } = customRange || this.getCurrentRange()
|
|
|
+ const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
|
|
|
+ getOrderGmv({ startDate: start, endDate: end }),
|
|
|
+ getOrderRBig({ startDate: start, endDate: end }),
|
|
|
+ getOrderTop5Percentage({ startDate: start, endDate: end }),
|
|
|
+ getOrderAveragePaymentTime({ startDate: start, endDate: end })
|
|
|
+ ])
|
|
|
+
|
|
|
+ const gmvValue = gmvRes || 0
|
|
|
+ this.kpiData.gmv = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(gmvValue)
|
|
|
+ this.kpiData.p80Contribution = `${Math.round(p80Res?.rBigRatio || 0)}%`
|
|
|
+ this.kpiData.top5Contribution = `${Math.round(top5Res?.data?.top5Percentage || 0)}%`
|
|
|
+
|
|
|
+ const avgSeconds = avgTimeRes?.averagePaymentSeconds || 0
|
|
|
+ const minutes = Math.floor(avgSeconds / 60)
|
|
|
+ const seconds = Math.round(avgSeconds % 60)
|
|
|
+ this.kpiData.averagePaymentTime = `${this.pad2(minutes)}:${this.pad2(seconds)} 秒`
|
|
|
|
|
|
- async selectDateRange(type) {
|
|
|
-
|
|
|
- this.activeTab = type;
|
|
|
-
|
|
|
- const baseDate = this.toDate(this.selectedDate);
|
|
|
-
|
|
|
- let start;
|
|
|
-
|
|
|
- let end;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- if (type === '7d') {
|
|
|
-
|
|
|
- end = this.formatYmd(baseDate);
|
|
|
-
|
|
|
- start = this.formatYmd(this.addDays(baseDate, -6));
|
|
|
-
|
|
|
- } else if (type === 'tm') {
|
|
|
-
|
|
|
- start = this.formatYmd(this.startOfMonth(baseDate));
|
|
|
-
|
|
|
- end = this.formatYmd(this.endOfMonth(baseDate));
|
|
|
-
|
|
|
- } else if (type === 'lm') {
|
|
|
-
|
|
|
- const lastMonth = this.addMonths(baseDate, -1);
|
|
|
-
|
|
|
- start = this.formatYmd(this.startOfMonth(lastMonth));
|
|
|
-
|
|
|
- end = this.formatYmd(this.endOfMonth(lastMonth));
|
|
|
-
|
|
|
- } else if (type === 'all') {
|
|
|
-
|
|
|
- start = '2022-01-01';
|
|
|
-
|
|
|
- end = this.maxDate || this.formatYmd(baseDate);
|
|
|
-
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- this.currentDateRange = { start, end };
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- if (type === 'tm') {
|
|
|
-
|
|
|
- await this.fetchAllApiData({ start, end });
|
|
|
-
|
|
|
- const previousMonthData = await this.fetchPreviousMonthData(start);
|
|
|
-
|
|
|
- if (previousMonthData) {
|
|
|
-
|
|
|
- const currentGmv = parseFloat(this.kpiData.gmv.replace(/[?,]/g, '')) || 0;
|
|
|
-
|
|
|
- const currentP80 = parseFloat(this.kpiData.p80Contribution.replace('%', '')) || 0;
|
|
|
-
|
|
|
- const currentTop5 = parseFloat(this.kpiData.top5Contribution.replace('%', '')) || 0;
|
|
|
-
|
|
|
- const currentAvgTime = this.parseTimeString(this.kpiData.averagePaymentTime) || 0;
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- this.kpiData.gmvTrend = this.calculateTrend(currentGmv, previousMonthData.gmv);
|
|
|
-
|
|
|
- this.kpiData.p80Trend = this.calculateTrend(currentP80, previousMonthData.p80);
|
|
|
-
|
|
|
- this.kpiData.top5Trend = this.calculateTrend(currentTop5, previousMonthData.top5);
|
|
|
-
|
|
|
- this.kpiData.avgTimeTrend = this.calculateTrend(currentAvgTime, previousMonthData.avgTime);
|
|
|
-
|
|
|
+ if (this.activeTab !== 'tm') {
|
|
|
+ this.kpiData.gmvTrend = '+0%'
|
|
|
+ this.kpiData.p80Trend = '+0%'
|
|
|
+ this.kpiData.top5Trend = '+0%'
|
|
|
+ this.kpiData.avgTimeTrend = '+0%'
|
|
|
}
|
|
|
-
|
|
|
- } else {
|
|
|
-
|
|
|
- this.fetchAllApiData({ start, end });
|
|
|
-
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取订单价值数据失败:', error)
|
|
|
}
|
|
|
-
|
|
|
},
|
|
|
-
|
|
|
- async fetchAllKpiData() {
|
|
|
-
|
|
|
+ finishDashboardLoading() {
|
|
|
+ this.dashboardLoading = false
|
|
|
+ },
|
|
|
+ calculateTrend(currentValue, previousValue) {
|
|
|
+ if (previousValue === 0) return '+0%'
|
|
|
+ const change = ((currentValue - previousValue) / previousValue) * 100
|
|
|
+ const sign = change >= 0 ? '+' : ''
|
|
|
+ return `${sign}${change.toFixed(1)}%`
|
|
|
+ },
|
|
|
+ parseTimeString(timeString) {
|
|
|
+ const timeMatch = String(timeString || '').match(/(\d{2}):(\d{2})/)
|
|
|
+ if (!timeMatch) return 0
|
|
|
+ return parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)
|
|
|
+ },
|
|
|
+ getTrendColor(trend) {
|
|
|
+ return parseFloat(trend) >= 0 ? 'green' : 'red'
|
|
|
+ },
|
|
|
+ async fetchPreviousMonthData(currentStart) {
|
|
|
try {
|
|
|
+ const currentStartDate = this.toDate(currentStart)
|
|
|
+ const previousMonthBase = this.addMonths(currentStartDate, -1)
|
|
|
+ const previousMonthStart = this.formatYmd(this.startOfMonth(previousMonthBase))
|
|
|
+ const previousMonthEnd = this.formatYmd(this.endOfMonth(previousMonthBase))
|
|
|
|
|
|
const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
|
|
|
+ getOrderGmv({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
+ getOrderRBig({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
+ getOrderTop5Percentage({ startDate: previousMonthStart, endDate: previousMonthEnd }),
|
|
|
+ getOrderAveragePaymentTime({ startDate: previousMonthStart, endDate: previousMonthEnd })
|
|
|
+ ])
|
|
|
|
|
|
- getOrderGmv(),
|
|
|
-
|
|
|
- getOrderRBig(),
|
|
|
-
|
|
|
- getOrderTop5Percentage(),
|
|
|
-
|
|
|
- getOrderAveragePaymentTime()
|
|
|
-
|
|
|
- ]);
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
- const gmvValue = gmvRes || 0;
|
|
|
-
|
|
|
- this.kpiData.gmv = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(gmvValue);
|
|
|
-
|
|
|
- const p80Value = p80Res?.rBigRatio || 0;
|
|
|
-
|
|
|
- this.kpiData.p80Contribution = `${Math.round(p80Value)}%`;
|
|
|
-
|
|
|
- const top5Value = top5Res?.data?.top5Percentage || 0;
|
|
|
-
|
|
|
- this.kpiData.top5Contribution = `${Math.round(top5Value)}%`;
|
|
|
-
|
|
|
- const avgSeconds = avgTimeRes?.averagePaymentSeconds || 0;
|
|
|
-
|
|
|
- const minutes = Math.floor(avgSeconds / 60);
|
|
|
-
|
|
|
- const seconds = Math.round(avgSeconds % 60);
|
|
|
-
|
|
|
- this.kpiData.averagePaymentTime = `${this.pad2(minutes)}:${this.pad2(seconds)} 秒`;
|
|
|
-
|
|
|
+ return {
|
|
|
+ gmv: gmvRes || 0,
|
|
|
+ p80: p80Res?.rBigRatio || 0,
|
|
|
+ top5: top5Res?.data?.top5Percentage || 0,
|
|
|
+ avgTime: avgTimeRes?.averagePaymentSeconds || 0
|
|
|
+ }
|
|
|
} catch (error) {
|
|
|
-
|
|
|
- console.error('获取KPI数据失败:', error);
|
|
|
-
|
|
|
- this.kpiData.gmv = '?0';
|
|
|
-
|
|
|
- this.kpiData.p80Contribution = '0%';
|
|
|
-
|
|
|
- this.kpiData.top5Contribution = '0%';
|
|
|
-
|
|
|
- this.kpiData.averagePaymentTime = '00:00 秒';
|
|
|
-
|
|
|
+ console.error('获取上月订单价值数据失败:', error)
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async initDashboard() {
|
|
|
+ try {
|
|
|
+ const res = await getOrderMaxDate()
|
|
|
+ this.maxDate = res
|
|
|
+ this.selectedDate = res
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取订单最大日期失败:', error)
|
|
|
+ const today = this.formatYmd(new Date())
|
|
|
+ this.maxDate = today
|
|
|
+ this.selectedDate = today
|
|
|
}
|
|
|
+ await this.selectDateRange(this.activeTab)
|
|
|
+ },
|
|
|
+ async selectDateRange(type) {
|
|
|
+ this.activeTab = type
|
|
|
+ const range = this.getCurrentRange(type)
|
|
|
+ this.currentDateRange = range
|
|
|
|
|
|
+ if (type === 'tm') {
|
|
|
+ await this.fetchAllApiData(range)
|
|
|
+ const previousMonthData = await this.fetchPreviousMonthData(range.start)
|
|
|
+ if (previousMonthData) {
|
|
|
+ const currentGmv = parseFloat(this.kpiData.gmv.replace(/[¥,]/g, '')) || 0
|
|
|
+ const currentP80 = parseFloat(this.kpiData.p80Contribution.replace('%', '')) || 0
|
|
|
+ const currentTop5 = parseFloat(this.kpiData.top5Contribution.replace('%', '')) || 0
|
|
|
+ const currentAvgTime = this.parseTimeString(this.kpiData.averagePaymentTime)
|
|
|
+
|
|
|
+ this.kpiData.gmvTrend = this.calculateTrend(currentGmv, previousMonthData.gmv)
|
|
|
+ this.kpiData.p80Trend = this.calculateTrend(currentP80, previousMonthData.p80)
|
|
|
+ this.kpiData.top5Trend = this.calculateTrend(currentTop5, previousMonthData.top5)
|
|
|
+ this.kpiData.avgTimeTrend = this.calculateTrend(currentAvgTime, previousMonthData.avgTime)
|
|
|
+ }
|
|
|
+ this.finishDashboardLoading()
|
|
|
+ } else {
|
|
|
+ await this.fetchAllApiData(range)
|
|
|
+ this.finishDashboardLoading()
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
}
|
|
|
-
|
|
|
-};
|
|
|
-
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
-
|
|
|
<style scoped>
|
|
|
-
|
|
|
-.header-controls {
|
|
|
-
|
|
|
- display: flex;
|
|
|
-
|
|
|
- align-items: center;
|
|
|
-
|
|
|
- gap: 15px;
|
|
|
-
|
|
|
- margin-top: 10px;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.control-label {
|
|
|
-
|
|
|
- font-size: 14px;
|
|
|
-
|
|
|
- color: #666;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.control-item-group {
|
|
|
-
|
|
|
- display: flex;
|
|
|
-
|
|
|
- align-items: center;
|
|
|
-
|
|
|
- gap: 10px;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.date-input {
|
|
|
-
|
|
|
- padding: 4px 8px;
|
|
|
-
|
|
|
- border: 1px solid #ddd;
|
|
|
-
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- font-size: 14px;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.time-tab {
|
|
|
-
|
|
|
- padding: 5px 15px;
|
|
|
-
|
|
|
- border: 1px solid #ddd;
|
|
|
-
|
|
|
- background-color: #fff;
|
|
|
-
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- cursor: pointer;
|
|
|
-
|
|
|
- font-size: 14px;
|
|
|
-
|
|
|
- transition: all 0.2s;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.time-tab:hover {
|
|
|
-
|
|
|
- border-color: #188df0;
|
|
|
-
|
|
|
- color: #188df0;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.time-tab.active {
|
|
|
-
|
|
|
- background-color: #188df0;
|
|
|
-
|
|
|
- color: #fff;
|
|
|
-
|
|
|
- border-color: #188df0;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.upload-btn {
|
|
|
-
|
|
|
- padding: 5px 12px;
|
|
|
-
|
|
|
- border: 1px solid #188df0;
|
|
|
-
|
|
|
- background-color: #188df0;
|
|
|
-
|
|
|
- color: #fff;
|
|
|
-
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- cursor: pointer;
|
|
|
-
|
|
|
- font-size: 13px;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.upload-btn:disabled {
|
|
|
-
|
|
|
- opacity: 0.7;
|
|
|
-
|
|
|
- cursor: not-allowed;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-/* 样式部分和之前一样,不用修改 */
|
|
|
-
|
|
|
.order-value-view {
|
|
|
-
|
|
|
display: flex;
|
|
|
-
|
|
|
flex-direction: column;
|
|
|
-
|
|
|
gap: 20px;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.page-header {
|
|
|
-
|
|
|
display: flex;
|
|
|
-
|
|
|
justify-content: space-between;
|
|
|
-
|
|
|
align-items: center;
|
|
|
-
|
|
|
- background-color: white;
|
|
|
-
|
|
|
+ background-color: #fff;
|
|
|
padding: 15px 20px;
|
|
|
-
|
|
|
border-radius: 8px;
|
|
|
-
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.page-title {
|
|
|
-
|
|
|
font-size: 18px;
|
|
|
-
|
|
|
font-weight: 600;
|
|
|
-
|
|
|
color: #333;
|
|
|
-
|
|
|
margin: 0;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.header-controls {
|
|
|
-
|
|
|
display: flex;
|
|
|
-
|
|
|
align-items: center;
|
|
|
-
|
|
|
- font-size: 14px;
|
|
|
-
|
|
|
+ gap: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
}
|
|
|
|
|
|
-.control-item {
|
|
|
-
|
|
|
- margin-left: 20px;
|
|
|
-
|
|
|
+.control-label {
|
|
|
+ font-size: 14px;
|
|
|
color: #666;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
-.time-range-selector {
|
|
|
-
|
|
|
+.control-item-group {
|
|
|
display: flex;
|
|
|
-
|
|
|
align-items: center;
|
|
|
-
|
|
|
+ gap: 8px;
|
|
|
}
|
|
|
|
|
|
-.date-input {
|
|
|
-
|
|
|
- padding: 6px 10px;
|
|
|
-
|
|
|
- border: 1px solid #ddd;
|
|
|
-
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- margin-right: 10px;
|
|
|
-
|
|
|
+.date-picker {
|
|
|
+ width: 150px;
|
|
|
}
|
|
|
|
|
|
.time-tab {
|
|
|
-
|
|
|
- background: white;
|
|
|
-
|
|
|
+ padding: 6px 14px;
|
|
|
border: 1px solid #ddd;
|
|
|
-
|
|
|
- padding: 8px 12px;
|
|
|
-
|
|
|
- margin-left: -1px;
|
|
|
-
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
cursor: pointer;
|
|
|
-
|
|
|
- font-size: 12px;
|
|
|
-
|
|
|
- color: #555;
|
|
|
-
|
|
|
+ font-size: 13px;
|
|
|
transition: all 0.2s;
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-.time-tab:first-of-type {
|
|
|
-
|
|
|
- border-top-left-radius: 4px;
|
|
|
-
|
|
|
- border-bottom-left-radius: 4px;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
-.time-tab:last-of-type {
|
|
|
-
|
|
|
- border-top-right-radius: 4px;
|
|
|
-
|
|
|
- border-bottom-right-radius: 4px;
|
|
|
-
|
|
|
+.time-tab:hover {
|
|
|
+ border-color: #188df0;
|
|
|
+ color: #188df0;
|
|
|
}
|
|
|
|
|
|
.time-tab.active {
|
|
|
-
|
|
|
- background-color: #3366CC;
|
|
|
-
|
|
|
- color: white;
|
|
|
-
|
|
|
- border-color: #3366CC;
|
|
|
-
|
|
|
- z-index: 1;
|
|
|
-
|
|
|
+ background-color: #188df0;
|
|
|
+ color: #fff;
|
|
|
+ border-color: #188df0;
|
|
|
}
|
|
|
|
|
|
.kpi-cards-grid {
|
|
|
-
|
|
|
+ position: relative;
|
|
|
display: grid;
|
|
|
-
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
-
|
|
|
gap: 20px;
|
|
|
-
|
|
|
+ min-height: 146px;
|
|
|
}
|
|
|
|
|
|
.charts-area {
|
|
|
-
|
|
|
display: flex;
|
|
|
-
|
|
|
flex-direction: column;
|
|
|
-
|
|
|
gap: 20px;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.leakage-section {
|
|
|
-
|
|
|
display: flex;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.full-width {
|
|
|
-
|
|
|
width: 100%;
|
|
|
-
|
|
|
}
|
|
|
|
|
|
.chart-wrapper {
|
|
|
-
|
|
|
- background-color: white;
|
|
|
-
|
|
|
+ background-color: #fff;
|
|
|
padding: 15px;
|
|
|
-
|
|
|
border-radius: 8px;
|
|
|
-
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
|
-
|
|
|
}
|
|
|
-
|
|
|
</style>
|
|
|
-
|