index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. <template>
  2. <div class="department-efficiency-view">
  3. <header class="page-header">
  4. <h1 class="page-title">综合运营分析</h1>
  5. <p class="page-description">分析各部门的平均销售额与渠道商品多样性。</p>
  6. </header>
  7. <OrderDateRangeFilter
  8. :max-date="maxDate"
  9. default-period="day"
  10. storage-key="dtm-shop-date-filter"
  11. @change="handleFilterChange"
  12. />
  13. <section class="chart-area">
  14. <OrderLoadingPanel
  15. v-if="barChart.loading"
  16. title="正在加载部门效率数据"
  17. detail="正在读取各部门平均销售额"
  18. />
  19. <div v-else-if="barChart.message" :class="['status-overlay', { error: barChart.isError }]">
  20. <p>{{ barChart.title }}</p>
  21. <p class="error-message">{{ barChart.message }}</p>
  22. </div>
  23. <div ref="barChartRef" class="chart-canvas"></div>
  24. </section>
  25. <section class="chart-area">
  26. <OrderLoadingPanel
  27. v-if="pieChart.loading"
  28. title="正在加载渠道多样性数据"
  29. detail="正在读取各渠道商品覆盖结构"
  30. />
  31. <div v-else-if="pieChart.message" :class="['status-overlay', { error: pieChart.isError }]">
  32. <p>{{ pieChart.title }}</p>
  33. <p class="error-message">{{ pieChart.message }}</p>
  34. </div>
  35. <div ref="pieChartRef" class="chart-canvas"></div>
  36. </section>
  37. </div>
  38. </template>
  39. <script>
  40. import * as echarts from 'echarts'
  41. import OrderLoadingPanel from '../components/OrderLoadingPanel.vue'
  42. import OrderDateRangeFilter from '../components/OrderDateRangeFilter.vue'
  43. import { getShopChannelDiversity, getShopDepartmentEfficiency, getShopMaxDate } from '@/api/order'
  44. const EMPTY_DATA_HINTS = ['未上传', '请先上传', '暂无数据', '无数据', 'not found', 'no data']
  45. function createChartState(defaultTitle) {
  46. return {
  47. loading: true,
  48. title: defaultTitle,
  49. message: '',
  50. isError: false
  51. }
  52. }
  53. export default {
  54. name: 'OrderEfficiency',
  55. components: {
  56. OrderLoadingPanel,
  57. OrderDateRangeFilter
  58. },
  59. data() {
  60. return {
  61. barChartInstance: null,
  62. pieChartInstance: null,
  63. maxDate: '',
  64. currentQuery: {},
  65. barChart: createChartState('部门效率数据暂不可用'),
  66. pieChart: createChartState('渠道多样性数据暂不可用')
  67. }
  68. },
  69. mounted() {
  70. this.initFilter()
  71. window.addEventListener('resize', this.handleResize)
  72. },
  73. beforeDestroy() {
  74. window.removeEventListener('resize', this.handleResize)
  75. this.disposeCharts()
  76. },
  77. methods: {
  78. async loadCharts() {
  79. await Promise.all([this.initBarChart(), this.initPieChart()])
  80. },
  81. async initFilter() {
  82. try {
  83. this.maxDate = await getShopMaxDate()
  84. } catch (error) {
  85. const today = new Date()
  86. this.maxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
  87. }
  88. },
  89. handleFilterChange(params) {
  90. this.currentQuery = params || {}
  91. this.loadCharts()
  92. },
  93. disposeCharts() {
  94. if (this.barChartInstance) {
  95. this.barChartInstance.dispose()
  96. this.barChartInstance = null
  97. }
  98. if (this.pieChartInstance) {
  99. this.pieChartInstance.dispose()
  100. this.pieChartInstance = null
  101. }
  102. },
  103. resetChartState(target, title) {
  104. target.loading = true
  105. target.title = title
  106. target.message = ''
  107. target.isError = false
  108. },
  109. getErrorMessage(error, fallback) {
  110. return (
  111. error?.response?.data?.message ||
  112. error?.response?.data?.msg ||
  113. error?.message ||
  114. fallback
  115. )
  116. },
  117. isEmptyDataMessage(message) {
  118. const normalized = String(message || '').toLowerCase()
  119. return EMPTY_DATA_HINTS.some(item => normalized.includes(item.toLowerCase()))
  120. },
  121. setChartStatus(target, title, message, isError = false) {
  122. target.title = title
  123. target.message = message
  124. target.isError = isError
  125. },
  126. normalizeBarData(response) {
  127. if (!response || response.success !== true || !response.data) {
  128. throw new Error((response && response.message) || '部门效率数据加载失败')
  129. }
  130. const rows = Object.entries(response.data)
  131. .map(([name, value]) => ({
  132. name,
  133. value: Number(value)
  134. }))
  135. .filter(item => Number.isFinite(item.value))
  136. if (!rows.length) {
  137. throw new Error('请先上传店铺价值 CSV 文件')
  138. }
  139. return rows
  140. },
  141. normalizePieData(response) {
  142. if (!response || response.success !== true || !response.data) {
  143. throw new Error((response && response.message) || '渠道多样性数据加载失败')
  144. }
  145. const rows = Object.entries(response.data)
  146. .map(([name, value]) => ({
  147. name,
  148. value: Number(value)
  149. }))
  150. .filter(item => Number.isFinite(item.value) && item.value > 0)
  151. if (!rows.length) {
  152. throw new Error('请先上传店铺价值 CSV 文件')
  153. }
  154. return rows
  155. },
  156. initBarChartInstance() {
  157. const chartEl = this.$refs.barChartRef
  158. if (!chartEl) return null
  159. this.barChartInstance = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
  160. return this.barChartInstance
  161. },
  162. initPieChartInstance() {
  163. const chartEl = this.$refs.pieChartRef
  164. if (!chartEl) return null
  165. this.pieChartInstance = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl)
  166. return this.pieChartInstance
  167. },
  168. async initBarChart() {
  169. this.resetChartState(this.barChart, '部门效率数据暂不可用')
  170. try {
  171. const response = await getShopDepartmentEfficiency({
  172. startDate: this.currentQuery.startDate,
  173. endDate: this.currentQuery.endDate
  174. })
  175. const chartData = this.normalizeBarData(response)
  176. await this.$nextTick()
  177. const chart = this.initBarChartInstance()
  178. if (!chart) return
  179. chart.setOption({
  180. title: { text: '部门效率分析', left: 'center' },
  181. tooltip: {
  182. trigger: 'axis',
  183. axisPointer: { type: 'shadow' },
  184. formatter: '{b}<br/>平均销售额: {c} 元'
  185. },
  186. grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
  187. xAxis: {
  188. type: 'category',
  189. data: chartData.map(item => item.name),
  190. axisLabel: { rotate: 30, interval: 0 }
  191. },
  192. yAxis: {
  193. type: 'value',
  194. name: '平均销售额(元)'
  195. },
  196. series: [{
  197. name: '平均销售额',
  198. type: 'bar',
  199. data: chartData.map(item => Number(item.value.toFixed(2))),
  200. barWidth: '40%',
  201. itemStyle: {
  202. borderRadius: [5, 5, 0, 0],
  203. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  204. { offset: 0, color: '#83bff6' },
  205. { offset: 1, color: '#188df0' }
  206. ])
  207. }
  208. }]
  209. }, true)
  210. } catch (error) {
  211. const message = this.getErrorMessage(error, '部门效率数据加载失败')
  212. this.setChartStatus(
  213. this.barChart,
  214. this.isEmptyDataMessage(message) ? '暂无部门效率数据' : '部门效率数据加载失败',
  215. this.isEmptyDataMessage(message) ? '请先在店铺价值页面上传 CSV 文件,再查看该图表。' : message,
  216. !this.isEmptyDataMessage(message)
  217. )
  218. } finally {
  219. this.barChart.loading = false
  220. }
  221. },
  222. async initPieChart() {
  223. this.resetChartState(this.pieChart, '渠道多样性数据暂不可用')
  224. try {
  225. const response = await getShopChannelDiversity({
  226. startDate: this.currentQuery.startDate,
  227. endDate: this.currentQuery.endDate
  228. })
  229. const chartData = this.normalizePieData(response)
  230. await this.$nextTick()
  231. const chart = this.initPieChartInstance()
  232. if (!chart) return
  233. chart.setOption({
  234. title: { text: '渠道商品多样性', left: 'center' },
  235. tooltip: { trigger: 'item', formatter: '{a}<br/>{b}: {c} ({d}%)' },
  236. legend: { orient: 'vertical', left: 'left', top: '10%' },
  237. series: [{
  238. name: '渠道',
  239. type: 'pie',
  240. radius: [20, 140],
  241. center: ['50%', '60%'],
  242. roseType: 'area',
  243. itemStyle: { borderRadius: 5 },
  244. data: chartData
  245. }]
  246. }, true)
  247. } catch (error) {
  248. const message = this.getErrorMessage(error, '渠道多样性数据加载失败')
  249. this.setChartStatus(
  250. this.pieChart,
  251. this.isEmptyDataMessage(message) ? '暂无渠道多样性数据' : '渠道多样性数据加载失败',
  252. this.isEmptyDataMessage(message) ? '请先在店铺价值页面上传 CSV 文件,再查看该图表。' : message,
  253. !this.isEmptyDataMessage(message)
  254. )
  255. } finally {
  256. this.pieChart.loading = false
  257. }
  258. },
  259. handleResize() {
  260. if (this.barChartInstance) {
  261. this.barChartInstance.resize()
  262. }
  263. if (this.pieChartInstance) {
  264. this.pieChartInstance.resize()
  265. }
  266. }
  267. }
  268. }
  269. </script>
  270. <style scoped>
  271. .department-efficiency-view {
  272. display: flex;
  273. flex-direction: column;
  274. gap: 20px;
  275. padding: 20px;
  276. }
  277. .page-header {
  278. padding: 15px 20px;
  279. background-color: #fff;
  280. border-radius: 8px;
  281. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  282. }
  283. .page-title {
  284. margin: 0;
  285. font-size: 18px;
  286. font-weight: 600;
  287. color: #333;
  288. }
  289. .page-description {
  290. margin: 4px 0 0;
  291. font-size: 14px;
  292. color: #666;
  293. }
  294. .chart-area {
  295. position: relative;
  296. padding: 20px;
  297. background-color: #fff;
  298. border-radius: 8px;
  299. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  300. min-height: 540px;
  301. }
  302. .chart-canvas {
  303. width: 100%;
  304. height: 500px;
  305. }
  306. .status-overlay {
  307. position: absolute;
  308. inset: 0;
  309. display: flex;
  310. flex-direction: column;
  311. justify-content: center;
  312. align-items: center;
  313. padding: 24px;
  314. text-align: center;
  315. background-color: rgba(255, 255, 255, 0.88);
  316. border-radius: 8px;
  317. z-index: 10;
  318. color: #555;
  319. font-size: 16px;
  320. }
  321. .status-overlay.error {
  322. color: #f56c6c;
  323. }
  324. .error-message {
  325. margin-top: 8px;
  326. font-size: 14px;
  327. color: #999;
  328. }
  329. </style>