index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. <template>
  2. <div class="shop-value-analysis-page">
  3. <header class="page-header">
  4. <div>
  5. <h1 class="main-title">店铺价值分析</h1>
  6. <p class="subtitle">实时监控关键指标与趋势</p>
  7. </div>
  8. <div class="header-actions">
  9. <span class="control-label">时间筛选</span>
  10. <el-date-picker
  11. v-model="selectedDate"
  12. class="date-picker"
  13. type="date"
  14. value-format="yyyy-MM-dd"
  15. :clearable="false"
  16. :editable="false"
  17. :picker-options="pickerOptions"
  18. placeholder="选择上传数据日期"
  19. @change="handleDateChange"
  20. />
  21. <div class="tab-group">
  22. <button :class="['time-tab', { active: activeTab === 'day' }]" @click="applyDateFilter('day')">当天</button>
  23. <button :class="['time-tab', { active: activeTab === '7d' }]" @click="applyDateFilter('7d')">最近7天</button>
  24. <button :class="['time-tab', { active: activeTab === 'tm' }]" @click="applyDateFilter('tm')">本月</button>
  25. <button :class="['time-tab', { active: activeTab === 'lm' }]" @click="applyDateFilter('lm')">上月</button>
  26. <button :class="['time-tab', { active: activeTab === 'all' }]" @click="applyDateFilter('all')">全量数据</button>
  27. </div>
  28. </div>
  29. </header>
  30. <div class="upload-row">
  31. <button class="btn-upload" :disabled="uploadingShop" @click="triggerShopUpload">
  32. {{ uploadingShop ? '上传中...' : '上传CSV导入' }}
  33. </button>
  34. <input
  35. ref="shopUploadInput"
  36. type="file"
  37. accept=".csv"
  38. multiple
  39. style="display: none;"
  40. @change="handleShopUploadChange"
  41. >
  42. </div>
  43. <div class="chart-card">
  44. <div class="top-product-contribution-container">
  45. <div class="kpi-card-grid">
  46. <div class="kpi-card">
  47. <span class="kpi-title">总销售额</span>
  48. <span class="kpi-value">¥{{ formatNumber(topProductData.totalSales) }}</span>
  49. </div>
  50. <div class="kpi-card">
  51. <span class="kpi-title">Top 5 商品总销售额</span>
  52. <span class="kpi-value">¥{{ formatNumber(topProductData.top5TotalSales) }}</span>
  53. </div>
  54. <div class="kpi-card">
  55. <span class="kpi-title">Top 5 贡献占比</span>
  56. <span class="kpi-value">{{ (topProductData.contributionRatio * 100).toFixed(2) }}%</span>
  57. </div>
  58. </div>
  59. <div class="chart-card inner-chart-card">
  60. <h3 class="chart-title">Top 5 商品销售分布</h3>
  61. <div ref="donutChartRef" style="width: 100%; height: 400px;"></div>
  62. </div>
  63. </div>
  64. </div>
  65. <div class="charts-container">
  66. <div class="chart-card">
  67. <div class="card-header">
  68. <h3 class="chart-title">各部门业绩分析(销量 / 销售额)</h3>
  69. </div>
  70. <div class="chart-wrapper">
  71. <div ref="unitContributionChart" class="chart-instance"></div>
  72. </div>
  73. </div>
  74. <div class="chart-card">
  75. <div class="card-header">
  76. <h3 class="chart-title">各渠道业绩分析(销量 / 销售额)</h3>
  77. </div>
  78. <div class="chart-wrapper">
  79. <div ref="channelTotalChart" class="chart-instance"></div>
  80. </div>
  81. </div>
  82. <div class="chart-card">
  83. <div class="card-header">
  84. <h3 class="chart-title">各平台价值分析(销量 / 销售额)</h3>
  85. </div>
  86. <div class="chart-wrapper">
  87. <div ref="platformChart" class="chart-instance"></div>
  88. </div>
  89. </div>
  90. </div>
  91. </div>
  92. </template>
  93. <script>
  94. import * as echarts from 'echarts'
  95. import {
  96. getShopChannelContribution,
  97. getShopChannelRoiValue,
  98. getShopChannelTotalContribution,
  99. getShopMaxDate,
  100. getShopTopProductContribution,
  101. getShopUnitContribution,
  102. uploadShopValueFiles
  103. } from '@/api/order'
  104. export default {
  105. name: 'ShopValue',
  106. data() {
  107. return {
  108. topProductData: {
  109. totalSales: 0,
  110. top5TotalSales: 0,
  111. contributionRatio: 0,
  112. top5Products: []
  113. },
  114. uploadingShop: false,
  115. selectedDate: '',
  116. maxDate: '',
  117. activeTab: '7d',
  118. currentDateRange: { start: '', end: '' },
  119. pickerOptions: {
  120. disabledDate: time => {
  121. if (!this.maxDate) return false
  122. return time.getTime() > new Date(`${this.maxDate}T23:59:59`).getTime()
  123. }
  124. }
  125. }
  126. },
  127. mounted() {
  128. this.initDashboard()
  129. window.addEventListener('resize', this.handleResize)
  130. },
  131. beforeDestroy() {
  132. window.removeEventListener('resize', this.handleResize)
  133. },
  134. methods: {
  135. getQueryParams() {
  136. return {
  137. startDate: this.currentDateRange.start,
  138. endDate: this.currentDateRange.end
  139. }
  140. },
  141. triggerShopUpload() {
  142. if (!this.uploadingShop && this.$refs.shopUploadInput) {
  143. this.$refs.shopUploadInput.click()
  144. }
  145. },
  146. async handleShopUploadChange(event) {
  147. const files = Array.from(event?.target?.files || [])
  148. if (!files.length) return
  149. const invalid = files.find(file => !String(file.name || '').toLowerCase().endsWith('.csv'))
  150. if (invalid) {
  151. this.$modal.msgError('仅支持上传 CSV 文件')
  152. event.target.value = ''
  153. return
  154. }
  155. await this.uploadShopFiles(files)
  156. event.target.value = ''
  157. },
  158. async uploadShopFiles(files) {
  159. this.uploadingShop = true
  160. try {
  161. const formData = new FormData()
  162. files.forEach(file => formData.append('files', file))
  163. const data = await uploadShopValueFiles(formData)
  164. if (data?.success) {
  165. this.$modal.msgSuccess(data.message || '上传并导入成功')
  166. await this.initDashboard()
  167. return
  168. }
  169. if (data?.debug) {
  170. console.error('[shop-upload-debug]', data.debug)
  171. }
  172. this.$modal.msgError(data?.message || '上传导入失败')
  173. } catch (error) {
  174. if (error?.response?.data?.debug) {
  175. console.error('[shop-upload-debug]', error.response.data.debug)
  176. }
  177. const msg = error?.response?.data?.message || error?.message || '上传导入失败'
  178. this.$modal.msgError(msg)
  179. } finally {
  180. this.uploadingShop = false
  181. }
  182. },
  183. handleDateChange() {
  184. if (!this.selectedDate) {
  185. this.selectedDate = this.maxDate
  186. }
  187. this.applyDateFilter(this.activeTab)
  188. },
  189. pad2(value) {
  190. return String(value).padStart(2, '0')
  191. },
  192. toDate(value) {
  193. if (value instanceof Date) return new Date(value.getTime())
  194. if (!value) return new Date()
  195. return new Date(`${value}T00:00:00`)
  196. },
  197. formatYmd(value) {
  198. const date = this.toDate(value)
  199. return `${date.getFullYear()}-${this.pad2(date.getMonth() + 1)}-${this.pad2(date.getDate())}`
  200. },
  201. addDays(value, days) {
  202. const date = this.toDate(value)
  203. date.setDate(date.getDate() + days)
  204. return date
  205. },
  206. addMonths(value, months) {
  207. const date = this.toDate(value)
  208. date.setMonth(date.getMonth() + months)
  209. return date
  210. },
  211. startOfMonth(value) {
  212. const date = this.toDate(value)
  213. return new Date(date.getFullYear(), date.getMonth(), 1)
  214. },
  215. endOfMonth(value) {
  216. const date = this.toDate(value)
  217. return new Date(date.getFullYear(), date.getMonth() + 1, 0)
  218. },
  219. buildRange(type = this.activeTab) {
  220. const baseDate = this.toDate(this.selectedDate || this.maxDate || new Date())
  221. if (type === 'day') {
  222. const day = this.formatYmd(baseDate)
  223. return { start: day, end: day }
  224. }
  225. if (type === '7d') {
  226. return {
  227. start: this.formatYmd(this.addDays(baseDate, -6)),
  228. end: this.formatYmd(baseDate)
  229. }
  230. }
  231. if (type === 'tm') {
  232. return {
  233. start: this.formatYmd(this.startOfMonth(baseDate)),
  234. end: this.formatYmd(this.endOfMonth(baseDate))
  235. }
  236. }
  237. if (type === 'lm') {
  238. const lastMonth = this.addMonths(baseDate, -1)
  239. return {
  240. start: this.formatYmd(this.startOfMonth(lastMonth)),
  241. end: this.formatYmd(this.endOfMonth(lastMonth))
  242. }
  243. }
  244. return {
  245. start: '2022-01-01',
  246. end: this.maxDate || this.formatYmd(baseDate)
  247. }
  248. },
  249. async initDashboard() {
  250. try {
  251. const maxDate = await getShopMaxDate()
  252. this.maxDate = maxDate
  253. this.selectedDate = maxDate
  254. } catch (error) {
  255. console.error('获取店铺最大日期失败:', error)
  256. const today = this.formatYmd(new Date())
  257. this.maxDate = today
  258. this.selectedDate = today
  259. }
  260. await this.applyDateFilter(this.activeTab)
  261. },
  262. async applyDateFilter(type) {
  263. this.activeTab = type
  264. this.currentDateRange = this.buildRange(type)
  265. await this.fetchData()
  266. },
  267. formatNumber(num) {
  268. if (!num) return '0'
  269. return new Intl.NumberFormat().format(Number(num).toFixed(0))
  270. },
  271. handleResize() {
  272. const refs = this.$refs
  273. if (refs.unitContributionChart) echarts.getInstanceByDom(refs.unitContributionChart)?.resize()
  274. if (refs.channelTotalChart) echarts.getInstanceByDom(refs.channelTotalChart)?.resize()
  275. if (refs.platformChart) echarts.getInstanceByDom(refs.platformChart)?.resize()
  276. if (refs.donutChartRef) echarts.getInstanceByDom(refs.donutChartRef)?.resize()
  277. },
  278. initDonutChart(chartData) {
  279. const chartEl = this.$refs.donutChartRef
  280. if (!chartEl) return
  281. const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
  282. myChart.setOption({
  283. tooltip: {
  284. trigger: 'item',
  285. formatter: '{a}<br/>{b}: ¥{c} ({d}%)'
  286. },
  287. legend: {
  288. orient: 'horizontal',
  289. bottom: '0%',
  290. left: 'center'
  291. },
  292. series: [{
  293. name: '销售额',
  294. type: 'pie',
  295. radius: ['60%', '80%'],
  296. avoidLabelOverlap: false,
  297. label: {
  298. show: true,
  299. position: 'outside',
  300. formatter: '{b}: {d}%'
  301. },
  302. labelLine: {
  303. show: true,
  304. length: 15,
  305. length2: 10
  306. },
  307. emphasis: {
  308. label: {
  309. show: true,
  310. fontSize: 14,
  311. fontWeight: 'bold'
  312. }
  313. },
  314. data: chartData
  315. }]
  316. }, true)
  317. },
  318. async fetchTopProductData() {
  319. try {
  320. const response = await getShopTopProductContribution(this.getQueryParams())
  321. if (!response?.success) return
  322. const data = response.data || {}
  323. this.topProductData.totalSales = data.totalSales || 0
  324. this.topProductData.top5TotalSales = data.top5TotalSales || 0
  325. this.topProductData.contributionRatio = data.contributionRatio || 0
  326. this.topProductData.top5Products = data.top5Products || []
  327. const chartData = this.topProductData.top5Products.map(item => ({
  328. name: item.productCode,
  329. value: item.salesAmount
  330. }))
  331. this.initDonutChart(chartData)
  332. } catch (error) {
  333. console.error('获取 Top 5 商品贡献失败:', error)
  334. }
  335. },
  336. initDualIndicatorBarChart(chartEl, data, categoryKey, seriesConfig) {
  337. if (!chartEl) return
  338. const spacePerBar = 120
  339. chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
  340. const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
  341. const categories = data.map(item => item[categoryKey])
  342. const series = seriesConfig.map(({ key, name, color }) => ({
  343. name,
  344. type: 'bar',
  345. data: data.map(item => item[key]),
  346. barMaxWidth: '40px',
  347. itemStyle: { color, borderRadius: [4, 4, 0, 0] },
  348. yAxisIndex: seriesConfig.findIndex(config => config.key === key)
  349. }))
  350. myChart.setOption({
  351. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  352. legend: { data: seriesConfig.map(item => item.name), top: 0 },
  353. grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
  354. xAxis: {
  355. type: 'category',
  356. data: categories,
  357. axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
  358. axisTick: { alignWithLabel: true }
  359. },
  360. yAxis: seriesConfig.map(({ name, formatter }, index) => ({
  361. type: 'value',
  362. name,
  363. nameTextStyle: { color: '#666' },
  364. axisLabel: {
  365. color: '#666',
  366. formatter: formatter || (value => {
  367. if (value >= 1000000) return `${value / 1000000}M`
  368. if (value >= 1000) return `${value / 1000}K`
  369. return value
  370. })
  371. },
  372. position: index === 0 ? 'left' : 'right',
  373. offset: index === 1 ? 40 : 0
  374. })),
  375. series
  376. }, true)
  377. },
  378. initPlatformValueChart(chartEl, data) {
  379. if (!chartEl) return
  380. const spacePerBar = 140
  381. chartEl.style.width = `${Math.max(800, spacePerBar * data.length)}px`
  382. const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
  383. const categories = data.map(item => item.name)
  384. myChart.setOption({
  385. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  386. legend: { data: ['销量', '销售额'], top: 0 },
  387. grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
  388. xAxis: {
  389. type: 'category',
  390. data: categories,
  391. axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
  392. axisTick: { alignWithLabel: true }
  393. },
  394. yAxis: [
  395. {
  396. type: 'value',
  397. name: '销量',
  398. nameTextStyle: { color: '#666' },
  399. axisLabel: {
  400. color: '#5470c6',
  401. formatter: value => {
  402. if (value >= 1000000) return `${value / 1000000}M`
  403. if (value >= 1000) return `${value / 1000}K`
  404. return value
  405. }
  406. }
  407. },
  408. {
  409. type: 'value',
  410. name: '销售额(元)',
  411. nameTextStyle: { color: '#e9c46a' },
  412. axisLabel: {
  413. color: '#e9c46a',
  414. formatter: value => `¥${Number(value).toFixed(2)}`
  415. },
  416. position: 'right',
  417. offset: 40
  418. }
  419. ],
  420. series: [
  421. {
  422. name: '销量',
  423. type: 'bar',
  424. data: data.map(item => item.totalVolume),
  425. barMaxWidth: '40px',
  426. itemStyle: { color: '#5470c6', borderRadius: [4, 4, 0, 0] },
  427. yAxisIndex: 0
  428. },
  429. {
  430. name: '销售额',
  431. type: 'bar',
  432. data: data.map(item => item.avgOrderValue),
  433. barMaxWidth: '40px',
  434. itemStyle: { color: '#e9c46a', borderRadius: [4, 4, 0, 0] },
  435. yAxisIndex: 1
  436. }
  437. ]
  438. }, true)
  439. },
  440. async fetchData() {
  441. try {
  442. const params = this.getQueryParams()
  443. const [unitRes, channelTotalRes, channelContributionRes, channelRoiValueRes] = await Promise.all([
  444. getShopUnitContribution(params),
  445. getShopChannelTotalContribution(params),
  446. getShopChannelContribution(params),
  447. getShopChannelRoiValue(params)
  448. ])
  449. if (unitRes?.success && unitRes.data) {
  450. this.initDualIndicatorBarChart(this.$refs.unitContributionChart, unitRes.data, 'name', [
  451. { key: 'totalVolume', name: '销量', color: '#5470c6' },
  452. {
  453. key: 'totalAmount',
  454. name: '销售额(元)',
  455. color: '#91cc75',
  456. formatter: value => `¥${value >= 10000 ? `${value / 10000}w` : value}`
  457. }
  458. ])
  459. }
  460. if (channelTotalRes?.success && channelTotalRes.data) {
  461. this.initDualIndicatorBarChart(this.$refs.channelTotalChart, channelTotalRes.data, 'name', [
  462. { key: 'totalVolume', name: '销量', color: '#5470c6' },
  463. {
  464. key: 'totalAmount',
  465. name: '销售额(元)',
  466. color: '#e9c46a',
  467. formatter: value => `¥${value >= 10000 ? `${value / 10000}w` : value}`
  468. }
  469. ])
  470. }
  471. if (channelContributionRes?.success && channelRoiValueRes?.success && channelContributionRes.data && channelRoiValueRes.data) {
  472. const platformSales = Object.entries(channelContributionRes.data).map(([name, value]) => ({
  473. name,
  474. totalVolume: Number(value)
  475. }))
  476. const platformAvgOrder = Object.entries(channelRoiValueRes.data).map(([name, value]) => ({
  477. name,
  478. avgOrderValue: Number(value)
  479. }))
  480. const mergedData = platformSales
  481. .map(item => ({
  482. ...item,
  483. avgOrderValue: platformAvgOrder.find(avg => avg.name === item.name)?.avgOrderValue || 0
  484. }))
  485. .sort((a, b) => b.totalVolume - a.totalVolume)
  486. this.initPlatformValueChart(this.$refs.platformChart, mergedData)
  487. }
  488. await this.fetchTopProductData()
  489. } catch (error) {
  490. console.error('获取店铺价值数据失败:', error)
  491. }
  492. }
  493. }
  494. }
  495. </script>
  496. <style scoped>
  497. .shop-value-analysis-page {
  498. padding: 24px;
  499. background-color: #f4f7f9;
  500. font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
  501. }
  502. .page-header {
  503. margin-bottom: 20px;
  504. display: flex;
  505. justify-content: space-between;
  506. gap: 16px;
  507. flex-wrap: wrap;
  508. }
  509. .main-title {
  510. font-size: 24px;
  511. font-weight: 600;
  512. color: #1d2129;
  513. }
  514. .subtitle {
  515. font-size: 14px;
  516. color: #86909c;
  517. margin-top: 4px;
  518. }
  519. .header-actions {
  520. display: flex;
  521. align-items: center;
  522. gap: 12px;
  523. flex-wrap: wrap;
  524. }
  525. .control-label {
  526. font-size: 14px;
  527. color: #666;
  528. }
  529. .date-picker {
  530. width: 150px;
  531. }
  532. .tab-group {
  533. display: flex;
  534. gap: 8px;
  535. }
  536. .time-tab {
  537. padding: 6px 14px;
  538. border: 1px solid #ddd;
  539. background: #fff;
  540. border-radius: 4px;
  541. cursor: pointer;
  542. transition: all 0.2s;
  543. }
  544. .time-tab.active {
  545. background-color: #188df0;
  546. color: #fff;
  547. border-color: #188df0;
  548. }
  549. .upload-row {
  550. display: flex;
  551. justify-content: flex-end;
  552. margin-bottom: 20px;
  553. }
  554. .btn-upload {
  555. background-color: #2a7f62;
  556. color: #fff;
  557. border: none;
  558. padding: 12px 28px;
  559. border-radius: 8px;
  560. cursor: pointer;
  561. font-size: 16px;
  562. font-weight: 600;
  563. }
  564. .btn-upload:disabled {
  565. opacity: 0.7;
  566. cursor: not-allowed;
  567. }
  568. .charts-container {
  569. display: flex;
  570. flex-direction: column;
  571. gap: 20px;
  572. }
  573. .chart-card {
  574. background: #fff;
  575. padding: 24px;
  576. border-radius: 4px;
  577. border: 1px solid #e5e6eb;
  578. display: grid;
  579. grid-template-rows: auto 1fr;
  580. overflow: hidden;
  581. }
  582. .inner-chart-card {
  583. padding: 20px;
  584. border-radius: 8px;
  585. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  586. }
  587. .card-header {
  588. display: flex;
  589. justify-content: space-between;
  590. align-items: center;
  591. margin-bottom: 20px;
  592. }
  593. .chart-title {
  594. font-size: 16px;
  595. font-weight: 600;
  596. color: #1d2129;
  597. }
  598. .chart-wrapper {
  599. overflow-x: auto;
  600. overflow-y: hidden;
  601. min-width: 0;
  602. }
  603. .chart-instance {
  604. height: 400px;
  605. }
  606. .chart-wrapper::-webkit-scrollbar {
  607. height: 8px;
  608. }
  609. .chart-wrapper::-webkit-scrollbar-track {
  610. background: #f1f1f1;
  611. }
  612. .chart-wrapper::-webkit-scrollbar-thumb {
  613. background: #c9cdd4;
  614. border-radius: 4px;
  615. }
  616. .top-product-contribution-container {
  617. display: flex;
  618. flex-direction: column;
  619. gap: 20px;
  620. width: 100%;
  621. }
  622. .kpi-card-grid {
  623. display: grid;
  624. grid-template-columns: repeat(3, 1fr);
  625. gap: 20px;
  626. }
  627. .kpi-card {
  628. background-color: #fff;
  629. padding: 20px;
  630. border-radius: 8px;
  631. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  632. display: flex;
  633. flex-direction: column;
  634. }
  635. .kpi-title {
  636. font-size: 14px;
  637. color: #666;
  638. margin-bottom: 10px;
  639. }
  640. .kpi-value {
  641. font-size: 28px;
  642. font-weight: bold;
  643. color: #333;
  644. }
  645. </style>