index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. <template>
  2. <div class="shop-value-analysis-page">
  3. <!-- 1. 页面主标题 -->
  4. <header class="page-header">
  5. <div>
  6. <h1 class="main-title">店铺价值分析</h1>
  7. <p class="subtitle">实时监控关键指标与趋势</p>
  8. </div>
  9. </header>
  10. <!-- 新增:Top 5 商品贡献分析 (移到顶部) -->
  11. <div class="chart-card">
  12. <div class="top-product-contribution-container">
  13. <!-- 顶部的三个指标卡片 -->
  14. <div class="kpi-card-grid">
  15. <div class="kpi-card">
  16. <span class="kpi-title">总销售额</span>
  17. <span class="kpi-value">¥{{ formatNumber(topProductData.totalSales) }}</span>
  18. </div>
  19. <div class="kpi-card">
  20. <span class="kpi-title">Top 5 商品总销售额</span>
  21. <span class="kpi-value">¥{{ formatNumber(topProductData.top5TotalSales) }}</span>
  22. </div>
  23. <div class="kpi-card">
  24. <span class="kpi-title">Top 5 贡献占比</span>
  25. <span class="kpi-value">{{ (topProductData.contributionRatio * 100).toFixed(2) }}%</span>
  26. </div>
  27. </div>
  28. <!-- 下方的环形图 -->
  29. <div class="chart-card">
  30. <h3 class="chart-title">Top 5 商品销售分布</h3>
  31. <div ref="donutChartRef" style="width: 100%; height: 400px;"></div>
  32. </div>
  33. </div>
  34. </div>
  35. <!-- 3. 筛选与操作栏 -->
  36. <div class="filter-bar">
  37. <div class="filters">
  38. <span>筛选:</span>
  39. <select><option>全部时间</option></select>
  40. <select><option>全部渠道</option></select>
  41. <select><option>全部区域</option></select>
  42. </div>
  43. <div class="actions">
  44. <button class="btn-secondary">导出数据</button>
  45. <button class="btn-primary" @click="fetchData">刷新</button>
  46. </div>
  47. </div>
  48. <!-- 4. 图表卡片区域 -->
  49. <div class="charts-container">
  50. <!-- 部门图表:销量 + 销售金额 -->
  51. <div class="chart-card">
  52. <div class="card-header">
  53. <h3 class="chart-title">各部门业绩分析(销量/金额)</h3>
  54. </div>
  55. <div class="chart-wrapper">
  56. <div ref="unitContributionChart" class="chart-instance"></div>
  57. </div>
  58. </div>
  59. <!-- 渠道图表:销量 + 销售金额 -->
  60. <div class="chart-card">
  61. <div class="card-header">
  62. <h3 class="chart-title">各渠道业绩分析(销量/金额)</h3>
  63. </div>
  64. <div class="chart-wrapper">
  65. <div ref="channelTotalChart" class="chart-instance"></div>
  66. </div>
  67. </div>
  68. <!-- 平台图表:销量 + 销售金额 -->
  69. <div class="chart-card">
  70. <div class="card-header">
  71. <h3 class="chart-title">各平台价值分析(销量/金额)</h3>
  72. </div>
  73. <div class="chart-wrapper">
  74. <div ref="platformChart" class="chart-instance"></div>
  75. </div>
  76. </div>
  77. </div>
  78. </div>
  79. </template>
  80. <script>
  81. import axios from 'axios';
  82. import * as echarts from 'echarts';
  83. export default {
  84. name: 'ShopValue',
  85. data() {
  86. return {
  87. topProductData: {
  88. totalSales: 0,
  89. top5TotalSales: 0,
  90. contributionRatio: 0,
  91. top5Products: []
  92. }
  93. };
  94. },
  95. mounted() {
  96. this.fetchData();
  97. window.addEventListener('resize', this.handleResize);
  98. },
  99. beforeDestroy() {
  100. window.removeEventListener('resize', this.handleResize);
  101. },
  102. methods: {
  103. formatNumber(num) {
  104. if (!num) return '0';
  105. return new Intl.NumberFormat().format(Number(num).toFixed(0));
  106. },
  107. handleResize() {
  108. const refs = this.$refs;
  109. if (refs.unitContributionChart) echarts.getInstanceByDom(refs.unitContributionChart)?.resize();
  110. if (refs.channelTotalChart) echarts.getInstanceByDom(refs.channelTotalChart)?.resize();
  111. if (refs.platformChart) echarts.getInstanceByDom(refs.platformChart)?.resize();
  112. if (refs.donutChartRef) echarts.getInstanceByDom(refs.donutChartRef)?.resize();
  113. },
  114. initDonutChart(chartData) {
  115. const chartEl = this.$refs.donutChartRef;
  116. if (!chartEl) return;
  117. const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl);
  118. const option = {
  119. tooltip: {
  120. trigger: 'item',
  121. formatter: '{a} <br/>{b} : ?{c} ({d}%)'
  122. },
  123. legend: {
  124. orient: 'horizontal',
  125. bottom: '0%',
  126. left: 'center'
  127. },
  128. series: [
  129. {
  130. name: '???',
  131. type: 'pie',
  132. radius: ['60%', '80%'],
  133. avoidLabelOverlap: false,
  134. label: {
  135. show: true,
  136. position: 'outside',
  137. formatter: '{b}: {d}%'
  138. },
  139. labelLine: {
  140. show: true,
  141. length: 15,
  142. length2: 10
  143. },
  144. emphasis: {
  145. label: {
  146. show: true,
  147. fontSize: '14',
  148. fontWeight: 'bold'
  149. }
  150. },
  151. data: chartData
  152. }
  153. ]
  154. };
  155. myChart.setOption(option, true);
  156. return myChart;
  157. },
  158. async fetchTopProductData() {
  159. try {
  160. const response = await axios.get('/api/shop/import/top-product-contribution');
  161. if (response.data.success) {
  162. const data = response.data.data;
  163. this.topProductData.totalSales = data.totalSales;
  164. this.topProductData.top5TotalSales = data.top5TotalSales;
  165. this.topProductData.contributionRatio = data.contributionRatio;
  166. this.topProductData.top5Products = data.top5Products;
  167. const chartData = data.top5Products.map(p => ({
  168. name: p.productCode,
  169. value: p.salesAmount
  170. }));
  171. this.initDonutChart(chartData);
  172. }
  173. } catch (error) {
  174. console.error('??Top 5??????:', error);
  175. }
  176. },
  177. initDualIndicatorBarChart(chartEl, data, categoryKey, seriesConfig) {
  178. if (!chartEl) return;
  179. const spacePerBar = 120;
  180. const containerWidth = Math.max(800, spacePerBar * data.length);
  181. chartEl.style.width = `${containerWidth}px`;
  182. let myChart = echarts.getInstanceByDom(chartEl);
  183. if (!myChart) myChart = echarts.init(chartEl);
  184. const categories = data.map(item => item[categoryKey]);
  185. const series = seriesConfig.map(({ key, name, color, formatter }) => ({
  186. name,
  187. type: 'bar',
  188. data: data.map(item => item[key]),
  189. barMaxWidth: '40px',
  190. itemStyle: { color, borderRadius: [4, 4, 0, 0] },
  191. yAxisIndex: seriesConfig.findIndex(c => c.key === key)
  192. }));
  193. const option = {
  194. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  195. legend: { data: seriesConfig.map(c => c.name), top: 0 },
  196. grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
  197. xAxis: {
  198. type: 'category',
  199. data: categories,
  200. axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
  201. axisTick: { alignWithLabel: true }
  202. },
  203. yAxis: seriesConfig.map(({ name, formatter }, index) => ({
  204. type: 'value',
  205. name,
  206. nameTextStyle: { color: '#666' },
  207. axisLabel: {
  208. color: '#666',
  209. formatter: formatter || (value => {
  210. if (value >= 1000000) return (value / 1000000) + 'M';
  211. if (value >= 1000) return (value / 1000) + 'K';
  212. return value;
  213. })
  214. },
  215. position: index === 0 ? 'left' : 'right',
  216. offset: index === 1 ? 40 : 0
  217. })),
  218. series
  219. };
  220. myChart.setOption(option, true);
  221. return myChart;
  222. },
  223. initPlatformValueChart(chartEl, data) {
  224. if (!chartEl) return;
  225. const spacePerBar = 140;
  226. const containerWidth = Math.max(800, spacePerBar * data.length);
  227. chartEl.style.width = `${containerWidth}px`;
  228. let myChart = echarts.getInstanceByDom(chartEl);
  229. if (!myChart) myChart = echarts.init(chartEl);
  230. const categories = data.map(item => item.name);
  231. const option = {
  232. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  233. legend: { data: ['??', '????'], top: 0 },
  234. grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
  235. xAxis: {
  236. type: 'category',
  237. data: categories,
  238. axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
  239. axisTick: { alignWithLabel: true }
  240. },
  241. yAxis: [
  242. {
  243. type: 'value',
  244. name: '??',
  245. nameTextStyle: { color: '#666' },
  246. axisLabel: {
  247. color: '#5470c6',
  248. formatter: value => {
  249. if (value >= 1000000) return (value / 1000000) + 'M';
  250. if (value >= 1000) return (value / 1000) + 'K';
  251. return value;
  252. }
  253. },
  254. position: 'left'
  255. },
  256. {
  257. type: 'value',
  258. name: '????(?)',
  259. nameTextStyle: { color: '#e9c46a' },
  260. axisLabel: {
  261. color: '#e9c46a',
  262. formatter: value => `?${value.toFixed(2)}`
  263. },
  264. position: 'right',
  265. offset: 40
  266. }
  267. ],
  268. series: [
  269. {
  270. name: '??',
  271. type: 'bar',
  272. data: data.map(item => item.totalVolume),
  273. barMaxWidth: '40px',
  274. itemStyle: { color: '#5470c6', borderRadius: [4, 4, 0, 0] },
  275. yAxisIndex: 0
  276. },
  277. {
  278. name: '????',
  279. type: 'bar',
  280. data: data.map(item => item.avgOrderValue),
  281. barMaxWidth: '40px',
  282. itemStyle: { color: '#e9c46a', borderRadius: [4, 4, 0, 0] },
  283. yAxisIndex: 1
  284. }
  285. ]
  286. };
  287. myChart.setOption(option, true);
  288. return myChart;
  289. },
  290. async fetchData() {
  291. try {
  292. const [
  293. unitRes,
  294. channelTotalRes,
  295. channelContributionRes,
  296. channelRoiValueRes
  297. ] = await Promise.all([
  298. axios.get('/api/shop/import/unit-contribution'),
  299. axios.get('/api/shop/import/channel-total-contribution'),
  300. axios.get('/api/shop/import/channel-contribution'),
  301. axios.get('/api/shop/import/channel-roi-value')
  302. ]);
  303. if (unitRes.data.success && unitRes.data.data) {
  304. const sortedData = [...unitRes.data.data].sort((a, b) => b.totalAmount - a.totalAmount);
  305. this.initDualIndicatorBarChart(
  306. this.$refs.unitContributionChart,
  307. sortedData,
  308. 'name',
  309. [
  310. { key: 'totalVolume', name: '??', color: '#5470c6' },
  311. {
  312. key: 'totalAmount',
  313. name: '????(?)',
  314. color: '#91cc75',
  315. formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
  316. }
  317. ]
  318. );
  319. }
  320. if (channelTotalRes.data.success && channelTotalRes.data.data) {
  321. const sortedData = [...channelTotalRes.data.data].sort((a, b) => b.totalAmount - a.totalAmount);
  322. this.initDualIndicatorBarChart(
  323. this.$refs.channelTotalChart,
  324. sortedData,
  325. 'name',
  326. [
  327. { key: 'totalVolume', name: '??', color: '#5470c6' },
  328. {
  329. key: 'totalAmount',
  330. name: '????(?)',
  331. color: '#e9c46a',
  332. formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
  333. }
  334. ]
  335. );
  336. }
  337. if (
  338. channelContributionRes.data.success &&
  339. channelRoiValueRes.data.success &&
  340. channelContributionRes.data.data &&
  341. channelRoiValueRes.data.data
  342. ) {
  343. const platformSales = Object.entries(channelContributionRes.data.data).map(([name, value]) => ({
  344. name,
  345. totalVolume: Number(value)
  346. }));
  347. const platformAvgOrder = Object.entries(channelRoiValueRes.data.data).map(([name, value]) => ({
  348. name,
  349. avgOrderValue: Number(value)
  350. }));
  351. const mergedData = platformSales
  352. .map(sale => ({
  353. ...sale,
  354. avgOrderValue: platformAvgOrder.find(avg => avg.name === sale.name)?.avgOrderValue || 0
  355. }))
  356. .sort((a, b) => b.totalVolume - a.totalVolume);
  357. this.initPlatformValueChart(this.$refs.platformChart, mergedData);
  358. }
  359. this.fetchTopProductData();
  360. } catch (error) {
  361. console.error('??????:', error);
  362. }
  363. }
  364. }
  365. };
  366. </script>
  367. <style scoped>
  368. /* 保持原有样式不变 */
  369. .shop-value-analysis-page {
  370. padding: 24px;
  371. background-color: #f4f7f9;
  372. font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
  373. }
  374. .page-header {
  375. margin-bottom: 20px;
  376. }
  377. .main-title {
  378. font-size: 24px;
  379. font-weight: 600;
  380. color: #1d2129;
  381. }
  382. .subtitle {
  383. font-size: 14px;
  384. color: #86909c;
  385. margin-top: 4px;
  386. }
  387. .kpi-grid {
  388. display: grid;
  389. grid-template-columns: repeat(4, 1fr);
  390. gap: 20px;
  391. margin-bottom: 20px;
  392. }
  393. .kpi-card {
  394. background: #fff;
  395. padding: 20px;
  396. border-radius: 4px;
  397. border: 1px solid #e5e6eb;
  398. }
  399. .kpi-header {
  400. display: flex;
  401. justify-content: space-between;
  402. align-items: center;
  403. margin-bottom: 12px;
  404. }
  405. .kpi-title {
  406. font-size: 14px;
  407. color: #4e5969;
  408. }
  409. .kpi-value {
  410. font-size: 28px;
  411. font-weight: 700;
  412. color: #1d2129;
  413. }
  414. .kpi-comparison {
  415. font-size: 12px;
  416. margin-top: 8px;
  417. }
  418. .kpi-comparison.positive { color: #00b42a; }
  419. .kpi-comparison.negative { color: #f53f3f; }
  420. .filter-bar {
  421. display: flex;
  422. justify-content: space-between;
  423. align-items: center;
  424. background: #fff;
  425. padding: 16px 20px;
  426. border-radius: 4px;
  427. border: 1px solid #e5e6eb;
  428. margin-bottom: 20px;
  429. }
  430. .filters { display: flex; align-items: center; gap: 16px; font-size: 14px; color: #4e5969; }
  431. .filters select { padding: 4px 8px; border-radius: 4px; border: 1px solid #e5e6eb; }
  432. .actions { display: flex; gap: 12px; }
  433. .btn-primary { background-color: #1677ff; color: #fff; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
  434. .btn-secondary { background-color: #f2f3f5; color: #4e5969; border: 1px solid #e5e6eb; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
  435. .charts-container {
  436. display: flex;
  437. flex-direction: column;
  438. gap: 20px;
  439. }
  440. .chart-card {
  441. background: #fff;
  442. padding: 24px;
  443. border-radius: 4px;
  444. border: 1px solid #e5e6eb;
  445. display: grid;
  446. grid-template-rows: auto 1fr;
  447. overflow: hidden;
  448. }
  449. .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
  450. .chart-title { font-size: 16px; font-weight: 600; color: #1d2129; }
  451. .chart-wrapper { overflow-x: auto; overflow-y: hidden; min-width: 0; }
  452. .chart-instance { height: 400px; }
  453. .chart-wrapper::-webkit-scrollbar { height: 8px; }
  454. .chart-wrapper::-webkit-scrollbar-track { background: #f1f1f1; }
  455. .chart-wrapper::-webkit-scrollbar-thumb { background: #c9cdd4; border-radius: 4px; }
  456. /* 新增的Top 5商品贡献分析样式 */
  457. .top-product-contribution-container {
  458. display: flex;
  459. flex-direction: column;
  460. gap: 20px; /* 卡片和图表之间的间距 */
  461. width: 100%;
  462. }
  463. /* KPI卡片网格布局 */
  464. .kpi-card-grid {
  465. display: grid;
  466. grid-template-columns: repeat(3, 1fr); /* 三个卡片平分宽度 */
  467. gap: 20px; /* 卡片之间的间距 */
  468. }
  469. /* 单个KPI卡片的样式 */
  470. .kpi-card {
  471. background-color: #ffffff;
  472. padding: 20px;
  473. border-radius: 8px;
  474. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  475. display: flex;
  476. flex-direction: column;
  477. }
  478. .kpi-title {
  479. font-size: 14px;
  480. color: #666;
  481. margin-bottom: 10px;
  482. }
  483. .kpi-value {
  484. font-size: 28px;
  485. font-weight: bold;
  486. color: #333;
  487. }
  488. /* 图表卡片的样式 */
  489. .chart-card {
  490. background-color: #ffffff;
  491. padding: 20px;
  492. border-radius: 8px;
  493. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  494. }
  495. .chart-title {
  496. font-size: 18px;
  497. color: #333;
  498. margin-bottom: 20px;
  499. }
  500. </style>