| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- <template>
- <div class="shop-value-analysis-page">
- <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"
- >
- </div>
- <div class="chart-card">
- <div class="top-product-contribution-container">
- <div class="kpi-card-grid">
- <div class="kpi-card">
- <span class="kpi-title">总销售额</span>
- <span class="kpi-value">¥{{ formatNumber(topProductData.totalSales) }}</span>
- </div>
- <div class="kpi-card">
- <span class="kpi-title">Top 5 商品总销售额</span>
- <span class="kpi-value">¥{{ formatNumber(topProductData.top5TotalSales) }}</span>
- </div>
- <div class="kpi-card">
- <span class="kpi-title">Top 5 贡献占比</span>
- <span class="kpi-value">{{ (topProductData.contributionRatio * 100).toFixed(2) }}%</span>
- </div>
- </div>
- <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>
- <div class="charts-container">
- <div class="chart-card">
- <div class="card-header">
- <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>
- </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>
- </div>
- <div class="chart-wrapper">
- <div ref="platformChart" class="chart-instance"></div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script>
- import * as echarts from 'echarts'
- import {
- getShopChannelContribution,
- getShopChannelRoiValue,
- getShopChannelTotalContribution,
- getShopMaxDate,
- getShopTopProductContribution,
- getShopUnitContribution,
- uploadShopValueFiles
- } from '@/api/order'
- export default {
- name: 'ShopValue',
- data() {
- return {
- topProductData: {
- totalSales: 0,
- top5TotalSales: 0,
- contributionRatio: 0,
- top5Products: []
- },
- 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.initDashboard()
- window.addEventListener('resize', this.handleResize)
- },
- beforeDestroy() {
- window.removeEventListener('resize', this.handleResize)
- },
- methods: {
- getQueryParams() {
- return {
- startDate: this.currentDateRange.start,
- endDate: this.currentDateRange.end
- }
- },
- triggerShopUpload() {
- if (!this.uploadingShop && this.$refs.shopUploadInput) {
- this.$refs.shopUploadInput.click()
- }
- },
- async handleShopUploadChange(event) {
- 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
- }
- await this.uploadShopFiles(files)
- event.target.value = ''
- },
- async uploadShopFiles(files) {
- this.uploadingShop = true
- try {
- 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?.debug) {
- console.error('[shop-upload-debug]', data.debug)
- }
- this.$modal.msgError(data?.message || '上传导入失败')
- } catch (error) {
- if (error?.response?.data?.debug) {
- console.error('[shop-upload-debug]', error.response.data.debug)
- }
- const msg = error?.response?.data?.message || error?.message || '上传导入失败'
- this.$modal.msgError(msg)
- } finally {
- 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))
- },
- 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()
- },
- initDonutChart(chartData) {
- 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}%)'
- },
- legend: {
- orient: 'horizontal',
- bottom: '0%',
- left: 'center'
- },
- 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,
- fontSize: 14,
- fontWeight: 'bold'
- }
- },
- data: chartData
- }]
- }, true)
- },
- async fetchTopProductData() {
- try {
- 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)
- }
- },
- initDualIndicatorBarChart(chartEl, data, categoryKey, seriesConfig) {
- if (!chartEl) return
- const spacePerBar = 120
- chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
- 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(config => config.key === key)
- }))
- myChart.setOption({
- tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
- legend: { data: seriesConfig.map(item => item.name), top: 0 },
- grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
- xAxis: {
- type: 'category',
- data: categories,
- axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
- axisTick: { alignWithLabel: true }
- },
- yAxis: seriesConfig.map(({ name, formatter }, index) => ({
- type: 'value',
- name,
- nameTextStyle: { color: '#666' },
- axisLabel: {
- color: '#666',
- formatter: formatter || (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
- }, true)
- },
- initPlatformValueChart(chartEl, data) {
- if (!chartEl) return
- const spacePerBar = 140
- chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
- const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
- const categories = data.map(item => item.name)
- myChart.setOption({
- tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
- legend: { data: ['销量', '销售额'], top: 0 },
- grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
- xAxis: {
- type: 'category',
- data: categories,
- axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
- axisTick: { alignWithLabel: true }
- },
- yAxis: [
- {
- type: 'value',
- name: '销量',
- nameTextStyle: { color: '#666' },
- axisLabel: {
- color: '#5470c6',
- formatter: value => {
- if (value >= 1000000) return `${value / 1000000}M`
- if (value >= 1000) return `${value / 1000}K`
- return value
- }
- }
- },
- {
- type: 'value',
- name: '销售额(元)',
- nameTextStyle: { color: '#e9c46a' },
- axisLabel: {
- color: '#e9c46a',
- formatter: value => `¥${Number(value).toFixed(2)}`
- },
- position: 'right',
- offset: 40
- }
- ],
- series: [
- {
- name: '销量',
- type: 'bar',
- data: data.map(item => item.totalVolume),
- barMaxWidth: '40px',
- itemStyle: { color: '#5470c6', borderRadius: [4, 4, 0, 0] },
- yAxisIndex: 0
- },
- {
- name: '销售额',
- type: 'bar',
- data: data.map(item => item.avgOrderValue),
- barMaxWidth: '40px',
- itemStyle: { color: '#e9c46a', borderRadius: [4, 4, 0, 0] },
- yAxisIndex: 1
- }
- ]
- }, true)
- },
- async fetchData() {
- try {
- 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) {
- 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) {
- 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(item => ({
- ...item,
- avgOrderValue: platformAvgOrder.find(avg => avg.name === item.name)?.avgOrderValue || 0
- }))
- .sort((a, b) => b.totalVolume - a.totalVolume)
- this.initPlatformValueChart(this.$refs.platformChart, mergedData)
- }
- await this.fetchTopProductData()
- } catch (error) {
- console.error('获取店铺价值数据失败:', error)
- }
- }
- }
- }
- </script>
- <style scoped>
- .shop-value-analysis-page {
- padding: 24px;
- background-color: #f4f7f9;
- font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
- }
- .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;
- }
- .header-actions {
- display: flex;
- align-items: center;
- gap: 12px;
- flex-wrap: wrap;
- }
- .control-label {
- font-size: 14px;
- color: #666;
- }
- .date-picker {
- width: 150px;
- }
- .tab-group {
- display: flex;
- gap: 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;
- }
- .upload-row {
- display: flex;
- justify-content: flex-end;
- margin-bottom: 20px;
- }
- .btn-upload {
- background-color: #2a7f62;
- color: #fff;
- border: none;
- padding: 12px 28px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 16px;
- font-weight: 600;
- }
- .btn-upload:disabled {
- opacity: 0.7;
- cursor: not-allowed;
- }
- .charts-container {
- display: flex;
- flex-direction: column;
- gap: 20px;
- }
- .chart-card {
- background: #fff;
- padding: 24px;
- border-radius: 4px;
- border: 1px solid #e5e6eb;
- display: grid;
- grid-template-rows: auto 1fr;
- overflow: hidden;
- }
- .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-product-contribution-container {
- display: flex;
- flex-direction: column;
- gap: 20px;
- width: 100%;
- }
- .kpi-card-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 20px;
- }
- .kpi-card {
- background-color: #fff;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
- display: flex;
- flex-direction: column;
- }
- .kpi-title {
- font-size: 14px;
- color: #666;
- margin-bottom: 10px;
- }
- .kpi-value {
- font-size: 28px;
- font-weight: bold;
- color: #333;
- }
- </style>
|