|
|
@@ -1,23 +1,49 @@
|
|
|
<template>
|
|
|
<div class="shop-value-analysis-page">
|
|
|
- <!-- 1. 页面主标题 -->
|
|
|
<header class="page-header">
|
|
|
<div>
|
|
|
<h1 class="main-title">店铺价值分析</h1>
|
|
|
<p class="subtitle">实时监控关键指标与趋势</p>
|
|
|
</div>
|
|
|
+ <div class="header-actions">
|
|
|
+ <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="tab-group">
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'day' }]" @click="applyDateFilter('day')">当天</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === '7d' }]" @click="applyDateFilter('7d')">最近7天</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'tm' }]" @click="applyDateFilter('tm')">本月</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'lm' }]" @click="applyDateFilter('lm')">上月</button>
|
|
|
+ <button :class="['time-tab', { active: activeTab === 'all' }]" @click="applyDateFilter('all')">全量数据</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</header>
|
|
|
|
|
|
- <!-- 上传操作(第一排) -->
|
|
|
<div class="upload-row">
|
|
|
- <button class="btn-upload" :disabled="uploadingShop" @click="triggerShopUpload">{{ uploadingShop ? '上传中...' : '上传CSV导入' }}</button>
|
|
|
- <input ref="shopUploadInput" type="file" accept=".csv" multiple style="display: none;" @change="handleShopUploadChange">
|
|
|
+ <button class="btn-upload" :disabled="uploadingShop" @click="triggerShopUpload">
|
|
|
+ {{ uploadingShop ? '上传中...' : '上传CSV导入' }}
|
|
|
+ </button>
|
|
|
+ <input
|
|
|
+ ref="shopUploadInput"
|
|
|
+ type="file"
|
|
|
+ accept=".csv"
|
|
|
+ multiple
|
|
|
+ style="display: none;"
|
|
|
+ @change="handleShopUploadChange"
|
|
|
+ >
|
|
|
</div>
|
|
|
|
|
|
- <!-- 新增:Top 5 商品贡献分析 (移到顶部) -->
|
|
|
<div class="chart-card">
|
|
|
<div class="top-product-contribution-container">
|
|
|
- <!-- 顶部的三个指标卡片 -->
|
|
|
<div class="kpi-card-grid">
|
|
|
<div class="kpi-card">
|
|
|
<span class="kpi-title">总销售额</span>
|
|
|
@@ -32,41 +58,36 @@
|
|
|
<span class="kpi-value">{{ (topProductData.contributionRatio * 100).toFixed(2) }}%</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <!-- 下方的环形图 -->
|
|
|
- <div class="chart-card">
|
|
|
+
|
|
|
+ <div class="chart-card inner-chart-card">
|
|
|
<h3 class="chart-title">Top 5 商品销售分布</h3>
|
|
|
<div ref="donutChartRef" style="width: 100%; height: 400px;"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 4. 图表卡片区域 -->
|
|
|
<div class="charts-container">
|
|
|
- <!-- 部门图表:销量 + 销售金额 -->
|
|
|
<div class="chart-card">
|
|
|
<div class="card-header">
|
|
|
- <h3 class="chart-title">各部门业绩分析(销量/金额)</h3>
|
|
|
+ <h3 class="chart-title">各部门业绩分析(销量 / 销售额)</h3>
|
|
|
</div>
|
|
|
<div class="chart-wrapper">
|
|
|
<div ref="unitContributionChart" class="chart-instance"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 渠道图表:销量 + 销售金额 -->
|
|
|
<div class="chart-card">
|
|
|
<div class="card-header">
|
|
|
- <h3 class="chart-title">各渠道业绩分析(销量/金额)</h3>
|
|
|
+ <h3 class="chart-title">各渠道业绩分析(销量 / 销售额)</h3>
|
|
|
</div>
|
|
|
<div class="chart-wrapper">
|
|
|
<div ref="channelTotalChart" class="chart-instance"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 平台图表:销量 + 销售金额 -->
|
|
|
<div class="chart-card">
|
|
|
<div class="card-header">
|
|
|
- <h3 class="chart-title">各平台价值分析(销量/金额)</h3>
|
|
|
+ <h3 class="chart-title">各平台价值分析(销量 / 销售额)</h3>
|
|
|
</div>
|
|
|
<div class="chart-wrapper">
|
|
|
<div ref="platformChart" class="chart-instance"></div>
|
|
|
@@ -77,15 +98,16 @@
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import * as echarts from 'echarts';
|
|
|
+import * as echarts from 'echarts'
|
|
|
import {
|
|
|
getShopChannelContribution,
|
|
|
getShopChannelRoiValue,
|
|
|
getShopChannelTotalContribution,
|
|
|
+ getShopMaxDate,
|
|
|
getShopTopProductContribution,
|
|
|
getShopUnitContribution,
|
|
|
uploadShopValueFiles
|
|
|
-} from '@/api/order';
|
|
|
+} from '@/api/order'
|
|
|
|
|
|
export default {
|
|
|
name: 'ShopValue',
|
|
|
@@ -97,161 +119,248 @@ export default {
|
|
|
contributionRatio: 0,
|
|
|
top5Products: []
|
|
|
},
|
|
|
- uploadingShop: false
|
|
|
- };
|
|
|
+ uploadingShop: false,
|
|
|
+ selectedDate: '',
|
|
|
+ maxDate: '',
|
|
|
+ activeTab: '7d',
|
|
|
+ currentDateRange: { start: '', end: '' },
|
|
|
+ pickerOptions: {
|
|
|
+ disabledDate: time => {
|
|
|
+ if (!this.maxDate) return false
|
|
|
+ return time.getTime() > new Date(`${this.maxDate}T23:59:59`).getTime()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
},
|
|
|
mounted() {
|
|
|
- this.fetchData();
|
|
|
- window.addEventListener('resize', this.handleResize);
|
|
|
+ this.initDashboard()
|
|
|
+ window.addEventListener('resize', this.handleResize)
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
- window.removeEventListener('resize', this.handleResize);
|
|
|
+ window.removeEventListener('resize', this.handleResize)
|
|
|
},
|
|
|
methods: {
|
|
|
+ getQueryParams() {
|
|
|
+ return {
|
|
|
+ startDate: this.currentDateRange.start,
|
|
|
+ endDate: this.currentDateRange.end
|
|
|
+ }
|
|
|
+ },
|
|
|
triggerShopUpload() {
|
|
|
- if (this.uploadingShop) return;
|
|
|
- if (this.$refs.shopUploadInput) {
|
|
|
- this.$refs.shopUploadInput.click();
|
|
|
+ if (!this.uploadingShop && this.$refs.shopUploadInput) {
|
|
|
+ this.$refs.shopUploadInput.click()
|
|
|
}
|
|
|
},
|
|
|
async handleShopUploadChange(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'));
|
|
|
+ const files = Array.from(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;
|
|
|
+ this.$modal.msgError('仅支持上传 CSV 文件')
|
|
|
+ event.target.value = ''
|
|
|
+ return
|
|
|
}
|
|
|
- await this.uploadShopFiles(files);
|
|
|
- event.target.value = '';
|
|
|
+ await this.uploadShopFiles(files)
|
|
|
+ event.target.value = ''
|
|
|
},
|
|
|
async uploadShopFiles(files) {
|
|
|
- this.uploadingShop = true;
|
|
|
+ this.uploadingShop = true
|
|
|
try {
|
|
|
- const formData = new FormData();
|
|
|
- files.forEach(file => formData.append('files', file));
|
|
|
- const data = await uploadShopValueFiles(formData);
|
|
|
- if (data && data.success) {
|
|
|
- this.$modal.msgSuccess(data.message || '上传并导入成功');
|
|
|
- await this.fetchData();
|
|
|
- return;
|
|
|
+ const formData = new FormData()
|
|
|
+ files.forEach(file => formData.append('files', file))
|
|
|
+ const data = await uploadShopValueFiles(formData)
|
|
|
+ if (data?.success) {
|
|
|
+ this.$modal.msgSuccess(data.message || '上传并导入成功')
|
|
|
+ await this.initDashboard()
|
|
|
+ return
|
|
|
}
|
|
|
- if (data && data.debug) {
|
|
|
- console.error('[shop-upload-debug]', data.debug);
|
|
|
+ if (data?.debug) {
|
|
|
+ console.error('[shop-upload-debug]', data.debug)
|
|
|
}
|
|
|
- this.$modal.msgError((data && data.message) || '上传导入失败');
|
|
|
+ this.$modal.msgError(data?.message || '上传导入失败')
|
|
|
} catch (error) {
|
|
|
- if (error && error.response && error.response.data && error.response.data.debug) {
|
|
|
- console.error('[shop-upload-debug]', error.response.data.debug);
|
|
|
+ if (error?.response?.data?.debug) {
|
|
|
+ console.error('[shop-upload-debug]', error.response.data.debug)
|
|
|
}
|
|
|
- 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);
|
|
|
+ const msg = error?.response?.data?.message || error?.message || '上传导入失败'
|
|
|
+ this.$modal.msgError(msg)
|
|
|
} finally {
|
|
|
- this.uploadingShop = false;
|
|
|
+ this.uploadingShop = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ handleDateChange() {
|
|
|
+ if (!this.selectedDate) {
|
|
|
+ this.selectedDate = this.maxDate
|
|
|
+ }
|
|
|
+ this.applyDateFilter(this.activeTab)
|
|
|
+ },
|
|
|
+ pad2(value) {
|
|
|
+ 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`)
|
|
|
+ },
|
|
|
+ formatYmd(value) {
|
|
|
+ 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
|
|
|
+ },
|
|
|
+ addMonths(value, months) {
|
|
|
+ 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)
|
|
|
+ },
|
|
|
+ endOfMonth(value) {
|
|
|
+ const date = this.toDate(value)
|
|
|
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
|
|
+ },
|
|
|
+ buildRange(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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (type === 'tm') {
|
|
|
+ return {
|
|
|
+ start: this.formatYmd(this.startOfMonth(baseDate)),
|
|
|
+ end: this.formatYmd(this.endOfMonth(baseDate))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (type === 'lm') {
|
|
|
+ const lastMonth = this.addMonths(baseDate, -1)
|
|
|
+ return {
|
|
|
+ start: this.formatYmd(this.startOfMonth(lastMonth)),
|
|
|
+ end: this.formatYmd(this.endOfMonth(lastMonth))
|
|
|
+ }
|
|
|
}
|
|
|
+ return {
|
|
|
+ start: '2022-01-01',
|
|
|
+ end: this.maxDate || this.formatYmd(baseDate)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async initDashboard() {
|
|
|
+ try {
|
|
|
+ const maxDate = await getShopMaxDate()
|
|
|
+ this.maxDate = maxDate
|
|
|
+ this.selectedDate = maxDate
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取店铺最大日期失败:', error)
|
|
|
+ const today = this.formatYmd(new Date())
|
|
|
+ this.maxDate = today
|
|
|
+ this.selectedDate = today
|
|
|
+ }
|
|
|
+ await this.applyDateFilter(this.activeTab)
|
|
|
+ },
|
|
|
+ async applyDateFilter(type) {
|
|
|
+ this.activeTab = type
|
|
|
+ this.currentDateRange = this.buildRange(type)
|
|
|
+ await this.fetchData()
|
|
|
},
|
|
|
formatNumber(num) {
|
|
|
- if (!num) return '0';
|
|
|
- return new Intl.NumberFormat().format(Number(num).toFixed(0));
|
|
|
+ if (!num) return '0'
|
|
|
+ return new Intl.NumberFormat().format(Number(num).toFixed(0))
|
|
|
},
|
|
|
handleResize() {
|
|
|
- const refs = this.$refs;
|
|
|
- if (refs.unitContributionChart) echarts.getInstanceByDom(refs.unitContributionChart)?.resize();
|
|
|
- if (refs.channelTotalChart) echarts.getInstanceByDom(refs.channelTotalChart)?.resize();
|
|
|
- if (refs.platformChart) echarts.getInstanceByDom(refs.platformChart)?.resize();
|
|
|
- if (refs.donutChartRef) echarts.getInstanceByDom(refs.donutChartRef)?.resize();
|
|
|
+ const refs = this.$refs
|
|
|
+ if (refs.unitContributionChart) echarts.getInstanceByDom(refs.unitContributionChart)?.resize()
|
|
|
+ if (refs.channelTotalChart) echarts.getInstanceByDom(refs.channelTotalChart)?.resize()
|
|
|
+ if (refs.platformChart) echarts.getInstanceByDom(refs.platformChart)?.resize()
|
|
|
+ if (refs.donutChartRef) echarts.getInstanceByDom(refs.donutChartRef)?.resize()
|
|
|
},
|
|
|
initDonutChart(chartData) {
|
|
|
- const chartEl = this.$refs.donutChartRef;
|
|
|
- if (!chartEl) return;
|
|
|
- const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl);
|
|
|
- const option = {
|
|
|
+ const chartEl = this.$refs.donutChartRef
|
|
|
+ if (!chartEl) return
|
|
|
+ const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
|
|
|
+ myChart.setOption({
|
|
|
tooltip: {
|
|
|
trigger: 'item',
|
|
|
- formatter: '{a} <br/>{b} : ?{c} ({d}%)'
|
|
|
+ formatter: '{a}<br/>{b}: ¥{c} ({d}%)'
|
|
|
},
|
|
|
legend: {
|
|
|
orient: 'horizontal',
|
|
|
bottom: '0%',
|
|
|
left: 'center'
|
|
|
},
|
|
|
- series: [
|
|
|
- {
|
|
|
- name: '销售额',
|
|
|
- type: 'pie',
|
|
|
- radius: ['60%', '80%'],
|
|
|
- avoidLabelOverlap: false,
|
|
|
+ series: [{
|
|
|
+ name: '销售额',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['60%', '80%'],
|
|
|
+ avoidLabelOverlap: false,
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'outside',
|
|
|
+ formatter: '{b}: {d}%'
|
|
|
+ },
|
|
|
+ labelLine: {
|
|
|
+ show: true,
|
|
|
+ length: 15,
|
|
|
+ length2: 10
|
|
|
+ },
|
|
|
+ emphasis: {
|
|
|
label: {
|
|
|
show: true,
|
|
|
- position: 'outside',
|
|
|
- formatter: '{b}: {d}%'
|
|
|
- },
|
|
|
- labelLine: {
|
|
|
- show: true,
|
|
|
- length: 15,
|
|
|
- length2: 10
|
|
|
- },
|
|
|
- emphasis: {
|
|
|
- label: {
|
|
|
- show: true,
|
|
|
- fontSize: '14',
|
|
|
- fontWeight: 'bold'
|
|
|
- }
|
|
|
- },
|
|
|
- data: chartData
|
|
|
- }
|
|
|
- ]
|
|
|
- };
|
|
|
- myChart.setOption(option, true);
|
|
|
- return myChart;
|
|
|
+ fontSize: 14,
|
|
|
+ fontWeight: 'bold'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: chartData
|
|
|
+ }]
|
|
|
+ }, true)
|
|
|
},
|
|
|
async fetchTopProductData() {
|
|
|
try {
|
|
|
- const response = await getShopTopProductContribution();
|
|
|
- if (response.success) {
|
|
|
- const data = response.data;
|
|
|
- this.topProductData.totalSales = data.totalSales;
|
|
|
- this.topProductData.top5TotalSales = data.top5TotalSales;
|
|
|
- this.topProductData.contributionRatio = data.contributionRatio;
|
|
|
- this.topProductData.top5Products = data.top5Products;
|
|
|
-
|
|
|
- const chartData = data.top5Products.map(p => ({
|
|
|
- name: p.productCode,
|
|
|
- value: p.salesAmount
|
|
|
- }));
|
|
|
- this.initDonutChart(chartData);
|
|
|
- }
|
|
|
+ const response = await getShopTopProductContribution(this.getQueryParams())
|
|
|
+ if (!response?.success) return
|
|
|
+ const data = response.data || {}
|
|
|
+ this.topProductData.totalSales = data.totalSales || 0
|
|
|
+ this.topProductData.top5TotalSales = data.top5TotalSales || 0
|
|
|
+ this.topProductData.contributionRatio = data.contributionRatio || 0
|
|
|
+ this.topProductData.top5Products = data.top5Products || []
|
|
|
+
|
|
|
+ const chartData = this.topProductData.top5Products.map(item => ({
|
|
|
+ name: item.productCode,
|
|
|
+ value: item.salesAmount
|
|
|
+ }))
|
|
|
+ this.initDonutChart(chartData)
|
|
|
} catch (error) {
|
|
|
- console.error('获取Top 5商品贡献失败:', error);
|
|
|
+ console.error('获取 Top 5 商品贡献失败:', error)
|
|
|
}
|
|
|
},
|
|
|
initDualIndicatorBarChart(chartEl, data, categoryKey, seriesConfig) {
|
|
|
- if (!chartEl) return;
|
|
|
- const spacePerBar = 120;
|
|
|
- const containerWidth = Math.max(800, spacePerBar * data.length);
|
|
|
- chartEl.style.width = `${containerWidth}px`;
|
|
|
-
|
|
|
- let myChart = echarts.getInstanceByDom(chartEl);
|
|
|
- if (!myChart) myChart = echarts.init(chartEl);
|
|
|
+ if (!chartEl) return
|
|
|
+ const spacePerBar = 120
|
|
|
+ chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
|
|
|
|
|
|
- const categories = data.map(item => item[categoryKey]);
|
|
|
- const series = seriesConfig.map(({ key, name, color, formatter }) => ({
|
|
|
+ const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
|
|
|
+ const categories = data.map(item => item[categoryKey])
|
|
|
+ const series = seriesConfig.map(({ key, name, color }) => ({
|
|
|
name,
|
|
|
type: 'bar',
|
|
|
data: data.map(item => item[key]),
|
|
|
barMaxWidth: '40px',
|
|
|
itemStyle: { color, borderRadius: [4, 4, 0, 0] },
|
|
|
- yAxisIndex: seriesConfig.findIndex(c => c.key === key)
|
|
|
- }));
|
|
|
+ yAxisIndex: seriesConfig.findIndex(config => config.key === key)
|
|
|
+ }))
|
|
|
|
|
|
- const option = {
|
|
|
+ myChart.setOption({
|
|
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
- legend: { data: seriesConfig.map(c => c.name), top: 0 },
|
|
|
+ legend: { data: seriesConfig.map(item => item.name), top: 0 },
|
|
|
grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
|
|
|
xAxis: {
|
|
|
type: 'category',
|
|
|
@@ -266,34 +375,28 @@ export default {
|
|
|
axisLabel: {
|
|
|
color: '#666',
|
|
|
formatter: formatter || (value => {
|
|
|
- if (value >= 1000000) return (value / 1000000) + 'M';
|
|
|
- if (value >= 1000) return (value / 1000) + 'K';
|
|
|
- return value;
|
|
|
+ if (value >= 1000000) return `${value / 1000000}M`
|
|
|
+ if (value >= 1000) return `${value / 1000}K`
|
|
|
+ return value
|
|
|
})
|
|
|
},
|
|
|
position: index === 0 ? 'left' : 'right',
|
|
|
offset: index === 1 ? 40 : 0
|
|
|
})),
|
|
|
series
|
|
|
- };
|
|
|
-
|
|
|
- myChart.setOption(option, true);
|
|
|
- return myChart;
|
|
|
+ }, true)
|
|
|
},
|
|
|
initPlatformValueChart(chartEl, data) {
|
|
|
- if (!chartEl) return;
|
|
|
- const spacePerBar = 140;
|
|
|
- const containerWidth = Math.max(800, spacePerBar * data.length);
|
|
|
- chartEl.style.width = `${containerWidth}px`;
|
|
|
-
|
|
|
- let myChart = echarts.getInstanceByDom(chartEl);
|
|
|
- if (!myChart) myChart = echarts.init(chartEl);
|
|
|
+ if (!chartEl) return
|
|
|
+ const spacePerBar = 140
|
|
|
+ chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
|
|
|
|
|
|
- const categories = data.map(item => item.name);
|
|
|
+ const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
|
|
|
+ const categories = data.map(item => item.name)
|
|
|
|
|
|
- const option = {
|
|
|
+ myChart.setOption({
|
|
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
- legend: { data: ['销量', '平均订单金额'], top: 0 },
|
|
|
+ legend: { data: ['销量', '销售额'], top: 0 },
|
|
|
grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
|
|
|
xAxis: {
|
|
|
type: 'category',
|
|
|
@@ -309,20 +412,19 @@ export default {
|
|
|
axisLabel: {
|
|
|
color: '#5470c6',
|
|
|
formatter: value => {
|
|
|
- if (value >= 1000000) return (value / 1000000) + 'M';
|
|
|
- if (value >= 1000) return (value / 1000) + 'K';
|
|
|
- return value;
|
|
|
+ if (value >= 1000000) return `${value / 1000000}M`
|
|
|
+ if (value >= 1000) return `${value / 1000}K`
|
|
|
+ return value
|
|
|
}
|
|
|
- },
|
|
|
- position: 'left'
|
|
|
+ }
|
|
|
},
|
|
|
{
|
|
|
type: 'value',
|
|
|
- name: '平均订单金额(元)',
|
|
|
+ name: '销售额(元)',
|
|
|
nameTextStyle: { color: '#e9c46a' },
|
|
|
axisLabel: {
|
|
|
color: '#e9c46a',
|
|
|
- formatter: value => `?${value.toFixed(2)}`
|
|
|
+ formatter: value => `¥${Number(value).toFixed(2)}`
|
|
|
},
|
|
|
position: 'right',
|
|
|
offset: 40
|
|
|
@@ -338,7 +440,7 @@ export default {
|
|
|
yAxisIndex: 0
|
|
|
},
|
|
|
{
|
|
|
- name: '平均订单金额',
|
|
|
+ name: '销售额',
|
|
|
type: 'bar',
|
|
|
data: data.map(item => item.avgOrderValue),
|
|
|
barMaxWidth: '40px',
|
|
|
@@ -346,99 +448,71 @@ export default {
|
|
|
yAxisIndex: 1
|
|
|
}
|
|
|
]
|
|
|
- };
|
|
|
-
|
|
|
- myChart.setOption(option, true);
|
|
|
- return myChart;
|
|
|
+ }, true)
|
|
|
},
|
|
|
async fetchData() {
|
|
|
try {
|
|
|
- const [
|
|
|
- unitRes,
|
|
|
- channelTotalRes,
|
|
|
- channelContributionRes,
|
|
|
- channelRoiValueRes
|
|
|
- ] = await Promise.all([
|
|
|
- getShopUnitContribution(),
|
|
|
- getShopChannelTotalContribution(),
|
|
|
- getShopChannelContribution(),
|
|
|
- getShopChannelRoiValue()
|
|
|
- ]);
|
|
|
-
|
|
|
- if (unitRes.success && unitRes.data) {
|
|
|
- const sortedData = [...unitRes.data].sort((a, b) => b.totalAmount - a.totalAmount);
|
|
|
- this.initDualIndicatorBarChart(
|
|
|
- this.$refs.unitContributionChart,
|
|
|
- sortedData,
|
|
|
- 'name',
|
|
|
- [
|
|
|
- { key: 'totalVolume', name: '销量', color: '#5470c6' },
|
|
|
- {
|
|
|
- key: 'totalAmount',
|
|
|
- name: '平均订单金额(元)',
|
|
|
- color: '#91cc75',
|
|
|
- formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
|
|
|
- }
|
|
|
- ]
|
|
|
- );
|
|
|
+ const params = this.getQueryParams()
|
|
|
+ const [unitRes, channelTotalRes, channelContributionRes, channelRoiValueRes] = await Promise.all([
|
|
|
+ getShopUnitContribution(params),
|
|
|
+ getShopChannelTotalContribution(params),
|
|
|
+ getShopChannelContribution(params),
|
|
|
+ getShopChannelRoiValue(params)
|
|
|
+ ])
|
|
|
+
|
|
|
+ if (unitRes?.success && unitRes.data) {
|
|
|
+ this.initDualIndicatorBarChart(this.$refs.unitContributionChart, unitRes.data, 'name', [
|
|
|
+ { key: 'totalVolume', name: '销量', color: '#5470c6' },
|
|
|
+ {
|
|
|
+ key: 'totalAmount',
|
|
|
+ name: '销售额(元)',
|
|
|
+ color: '#91cc75',
|
|
|
+ formatter: value => `¥${value >= 10000 ? `${value / 10000}w` : value}`
|
|
|
+ }
|
|
|
+ ])
|
|
|
}
|
|
|
|
|
|
- if (channelTotalRes.success && channelTotalRes.data) {
|
|
|
- const sortedData = [...channelTotalRes.data].sort((a, b) => b.totalAmount - a.totalAmount);
|
|
|
- this.initDualIndicatorBarChart(
|
|
|
- this.$refs.channelTotalChart,
|
|
|
- sortedData,
|
|
|
- 'name',
|
|
|
- [
|
|
|
- { key: 'totalVolume', name: '销量', color: '#5470c6' },
|
|
|
- {
|
|
|
- key: 'totalAmount',
|
|
|
- name: '平均订单金额(元)',
|
|
|
- color: '#e9c46a',
|
|
|
- formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
|
|
|
- }
|
|
|
- ]
|
|
|
- );
|
|
|
+ if (channelTotalRes?.success && channelTotalRes.data) {
|
|
|
+ this.initDualIndicatorBarChart(this.$refs.channelTotalChart, channelTotalRes.data, 'name', [
|
|
|
+ { key: 'totalVolume', name: '销量', color: '#5470c6' },
|
|
|
+ {
|
|
|
+ key: 'totalAmount',
|
|
|
+ name: '销售额(元)',
|
|
|
+ color: '#e9c46a',
|
|
|
+ formatter: value => `¥${value >= 10000 ? `${value / 10000}w` : value}`
|
|
|
+ }
|
|
|
+ ])
|
|
|
}
|
|
|
|
|
|
- if (
|
|
|
- channelContributionRes.success &&
|
|
|
- channelRoiValueRes.success &&
|
|
|
- channelContributionRes.data &&
|
|
|
- channelRoiValueRes.data
|
|
|
- ) {
|
|
|
+ if (channelContributionRes?.success && channelRoiValueRes?.success && channelContributionRes.data && channelRoiValueRes.data) {
|
|
|
const platformSales = Object.entries(channelContributionRes.data).map(([name, value]) => ({
|
|
|
name,
|
|
|
totalVolume: Number(value)
|
|
|
- }));
|
|
|
-
|
|
|
+ }))
|
|
|
const platformAvgOrder = Object.entries(channelRoiValueRes.data).map(([name, value]) => ({
|
|
|
name,
|
|
|
avgOrderValue: Number(value)
|
|
|
- }));
|
|
|
-
|
|
|
+ }))
|
|
|
const mergedData = platformSales
|
|
|
- .map(sale => ({
|
|
|
- ...sale,
|
|
|
- avgOrderValue: platformAvgOrder.find(avg => avg.name === sale.name)?.avgOrderValue || 0
|
|
|
+ .map(item => ({
|
|
|
+ ...item,
|
|
|
+ avgOrderValue: platformAvgOrder.find(avg => avg.name === item.name)?.avgOrderValue || 0
|
|
|
}))
|
|
|
- .sort((a, b) => b.totalVolume - a.totalVolume);
|
|
|
+ .sort((a, b) => b.totalVolume - a.totalVolume)
|
|
|
|
|
|
- this.initPlatformValueChart(this.$refs.platformChart, mergedData);
|
|
|
+ this.initPlatformValueChart(this.$refs.platformChart, mergedData)
|
|
|
}
|
|
|
|
|
|
- this.fetchTopProductData();
|
|
|
+ await this.fetchTopProductData()
|
|
|
} catch (error) {
|
|
|
- console.error('获取店铺价值数据失败:', error);
|
|
|
+ console.error('获取店铺价值数据失败:', error)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-};
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
-
|
|
|
<style scoped>
|
|
|
-/* 保持原有样式不变 */
|
|
|
.shop-value-analysis-page {
|
|
|
padding: 24px;
|
|
|
background-color: #f4f7f9;
|
|
|
@@ -447,57 +521,66 @@ export default {
|
|
|
|
|
|
.page-header {
|
|
|
margin-bottom: 20px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 16px;
|
|
|
+ flex-wrap: wrap;
|
|
|
}
|
|
|
+
|
|
|
.main-title {
|
|
|
font-size: 24px;
|
|
|
font-weight: 600;
|
|
|
color: #1d2129;
|
|
|
}
|
|
|
+
|
|
|
.subtitle {
|
|
|
font-size: 14px;
|
|
|
color: #86909c;
|
|
|
margin-top: 4px;
|
|
|
}
|
|
|
|
|
|
-.kpi-grid {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(4, 1fr);
|
|
|
- gap: 20px;
|
|
|
- margin-bottom: 20px;
|
|
|
-}
|
|
|
-.kpi-card {
|
|
|
- background: #fff;
|
|
|
- padding: 20px;
|
|
|
- border-radius: 4px;
|
|
|
- border: 1px solid #e5e6eb;
|
|
|
-}
|
|
|
-.kpi-header {
|
|
|
+.header-actions {
|
|
|
display: flex;
|
|
|
- justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- margin-bottom: 12px;
|
|
|
+ gap: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
}
|
|
|
-.kpi-title {
|
|
|
+
|
|
|
+.control-label {
|
|
|
font-size: 14px;
|
|
|
- color: #4e5969;
|
|
|
+ color: #666;
|
|
|
}
|
|
|
-.kpi-value {
|
|
|
- font-size: 28px;
|
|
|
- font-weight: 700;
|
|
|
- color: #1d2129;
|
|
|
+
|
|
|
+.date-picker {
|
|
|
+ width: 150px;
|
|
|
+}
|
|
|
+
|
|
|
+.tab-group {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
}
|
|
|
-.kpi-comparison {
|
|
|
- font-size: 12px;
|
|
|
- margin-top: 8px;
|
|
|
+
|
|
|
+.time-tab {
|
|
|
+ padding: 6px 14px;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.time-tab.active {
|
|
|
+ background-color: #188df0;
|
|
|
+ color: #fff;
|
|
|
+ border-color: #188df0;
|
|
|
}
|
|
|
-.kpi-comparison.positive { color: #00b42a; }
|
|
|
-.kpi-comparison.negative { color: #f53f3f; }
|
|
|
|
|
|
.upload-row {
|
|
|
display: flex;
|
|
|
justify-content: flex-end;
|
|
|
margin-bottom: 20px;
|
|
|
}
|
|
|
+
|
|
|
.btn-upload {
|
|
|
background-color: #2a7f62;
|
|
|
color: #fff;
|
|
|
@@ -508,13 +591,18 @@ export default {
|
|
|
font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
-.btn-upload:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
|
+
|
|
|
+.btn-upload:disabled {
|
|
|
+ opacity: 0.7;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
|
|
|
.charts-container {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 20px;
|
|
|
}
|
|
|
+
|
|
|
.chart-card {
|
|
|
background: #fff;
|
|
|
padding: 24px;
|
|
|
@@ -524,33 +612,64 @@ export default {
|
|
|
grid-template-rows: auto 1fr;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
-.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
|
|
-.chart-title { font-size: 16px; font-weight: 600; color: #1d2129; }
|
|
|
-.chart-wrapper { overflow-x: auto; overflow-y: hidden; min-width: 0; }
|
|
|
-.chart-instance { height: 400px; }
|
|
|
|
|
|
-.chart-wrapper::-webkit-scrollbar { height: 8px; }
|
|
|
-.chart-wrapper::-webkit-scrollbar-track { background: #f1f1f1; }
|
|
|
-.chart-wrapper::-webkit-scrollbar-thumb { background: #c9cdd4; border-radius: 4px; }
|
|
|
+.inner-chart-card {
|
|
|
+ padding: 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
|
+}
|
|
|
+
|
|
|
+.card-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2129;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrapper {
|
|
|
+ overflow-x: auto;
|
|
|
+ overflow-y: hidden;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-instance {
|
|
|
+ height: 400px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrapper::-webkit-scrollbar {
|
|
|
+ height: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrapper::-webkit-scrollbar-track {
|
|
|
+ background: #f1f1f1;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrapper::-webkit-scrollbar-thumb {
|
|
|
+ background: #c9cdd4;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
|
|
|
-/* 新增的Top 5商品贡献分析样式 */
|
|
|
.top-product-contribution-container {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 20px; /* 卡片和图表之间的间距 */
|
|
|
+ gap: 20px;
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
-/* KPI卡片网格布局 */
|
|
|
.kpi-card-grid {
|
|
|
display: grid;
|
|
|
- grid-template-columns: repeat(3, 1fr); /* 三个卡片平分宽度 */
|
|
|
- gap: 20px; /* 卡片之间的间距 */
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 20px;
|
|
|
}
|
|
|
|
|
|
-/* 单个KPI卡片的样式 */
|
|
|
.kpi-card {
|
|
|
- background-color: #ffffff;
|
|
|
+ background-color: #fff;
|
|
|
padding: 20px;
|
|
|
border-radius: 8px;
|
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
|
|
@@ -569,18 +688,4 @@ export default {
|
|
|
font-weight: bold;
|
|
|
color: #333;
|
|
|
}
|
|
|
-
|
|
|
-/* 图表卡片的样式 */
|
|
|
-.chart-card {
|
|
|
- background-color: #ffffff;
|
|
|
- padding: 20px;
|
|
|
- border-radius: 8px;
|
|
|
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
|
|
-}
|
|
|
-
|
|
|
-.chart-title {
|
|
|
- font-size: 18px;
|
|
|
- color: #333;
|
|
|
- margin-bottom: 20px;
|
|
|
-}
|
|
|
</style>
|