index.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  1. <template>
  2. <div class="app-container">
  3. <!-- 页面标题 -->
  4. <div class="page-header">
  5. <h2><i class="el-icon-s-data"></i> 销售整体看板</h2>
  6. <p class="page-desc">全局销售数据概览,包含关键指标和趋势分析</p>
  7. </div>
  8. <!-- 文件上传区域 -->
  9. <el-card class="mb-20">
  10. <div slot="header">
  11. <span><i class="el-icon-upload"></i> 数据文件上传</span>
  12. </div>
  13. <el-upload
  14. ref="upload"
  15. :limit="1"
  16. accept=".xlsx,.xls,.csv"
  17. :http-request="customUpload"
  18. :disabled="upload.isUploading"
  19. :on-progress="handleFileUploadProgress"
  20. :before-upload="beforeUpload"
  21. :auto-upload="false"
  22. drag
  23. >
  24. <i class="el-icon-upload"></i>
  25. <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
  26. <div class="el-upload__tip" slot="tip">
  27. <el-checkbox v-model="upload.updateSupport" /> 是否覆盖已上传的文件
  28. <div>只能上传xlsx/xls/csv文件,且不超过20MB</div>
  29. </div>
  30. </el-upload>
  31. <div style="margin-top: 15px">
  32. <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">立即上传并分析</el-button>
  33. <el-button @click="resetUpload">重置</el-button>
  34. </div>
  35. </el-card>
  36. <!-- 视图选择区域 -->
  37. <el-card v-if="results.summary" class="mb-20">
  38. <div slot="header">
  39. <span><i class="el-icon-s-operation"></i> 数据视图选择</span>
  40. </div>
  41. <div class="view-selector">
  42. <el-radio-group v-model="selectedView" @change="handleViewChange">
  43. <el-radio-button label="overall">总体概览</el-radio-button>
  44. <el-radio-button label="category">按品类查看</el-radio-button>
  45. <el-radio-button label="sku">按SKU查看</el-radio-button>
  46. </el-radio-group>
  47. <!-- 品类选择 -->
  48. <el-select v-if="selectedView === 'category' && results.category_list && results.category_list.length > 0"
  49. v-model="selectedCategory"
  50. placeholder="选择品类"
  51. @change="handleCategoryChange"
  52. style="margin-left: 10px; width: 200px;">
  53. <el-option v-for="category in results.category_list"
  54. :key="category"
  55. :label="category"
  56. :value="category" />
  57. </el-select>
  58. <!-- SKU选择 -->
  59. <el-select v-if="(selectedView === 'sku' || selectedView === 'category') && getAvailableSkus().length > 0"
  60. v-model="selectedSku"
  61. placeholder="选择SKU"
  62. @change="handleSkuChange"
  63. style="margin-left: 10px; width: 200px;">
  64. <el-option v-for="sku in getAvailableSkus()"
  65. :key="sku"
  66. :label="sku"
  67. :value="sku" />
  68. </el-select>
  69. </div>
  70. </el-card>
  71. <!-- 关键指标卡片 -->
  72. <el-row :gutter="20" class="mb-20">
  73. <el-col :xs="24" :sm="12" :md="8" :lg="6">
  74. <el-card class="stat-card">
  75. <div class="stat-content">
  76. <div class="stat-info">
  77. <p class="stat-label">当前总销量</p>
  78. <p class="stat-value">{{ totalSales }}</p>
  79. <p class="stat-desc" :class="salesGrowthRate >= 0 ? 'stat-desc-success' : 'stat-desc-error'">
  80. 增长率 {{ salesGrowthRate >= 0 ? '+' : '' }}{{ salesGrowthRate }}%
  81. </p>
  82. </div>
  83. <div class="stat-icon stat-icon-blue">
  84. <i class="el-icon-s-order"></i>
  85. </div>
  86. </div>
  87. </el-card>
  88. </el-col>
  89. <el-col :xs="24" :sm="12" :md="8" :lg="6">
  90. <el-card class="stat-card">
  91. <div class="stat-content">
  92. <div class="stat-info">
  93. <p class="stat-label">平均价格</p>
  94. <p class="stat-value">¥{{ avgPrice.toFixed(2) }}</p>
  95. <p class="stat-desc" :class="priceChange >= 0 ? 'stat-desc-success' : 'stat-desc-error'">
  96. 变化 {{ priceChange >= 0 ? '+' : '' }}{{ priceChange.toFixed(2) }}%
  97. </p>
  98. </div>
  99. <div class="stat-icon stat-icon-green">
  100. <i class="el-icon-s-finance"></i>
  101. </div>
  102. </div>
  103. </el-card>
  104. </el-col>
  105. <el-col :xs="24" :sm="12" :md="8" :lg="6">
  106. <el-card class="stat-card">
  107. <div class="stat-content">
  108. <div class="stat-info">
  109. <p class="stat-label">平均促销力度</p>
  110. <p class="stat-value">{{ avgPromotion.toFixed(2) }}%</p>
  111. <p class="stat-desc" :class="promotionChange >= 0 ? 'stat-desc-success' : 'stat-desc-error'">
  112. 变化 {{ promotionChange >= 0 ? '+' : '' }}{{ promotionChange.toFixed(2) }}%
  113. </p>
  114. </div>
  115. <div class="stat-icon stat-icon-yellow">
  116. <i class="el-icon-s-marketing"></i>
  117. </div>
  118. </div>
  119. </el-card>
  120. </el-col>
  121. <el-col :xs="24" :sm="12" :md="8" :lg="6">
  122. <el-card class="stat-card">
  123. <div class="stat-content">
  124. <div class="stat-info">
  125. <p class="stat-label">异常数据检测率</p>
  126. <p class="stat-value">{{ anomalyDetectionRate.toFixed(2) }}%</p>
  127. <p class="stat-desc">基于销售数据异常模式分析</p>
  128. </div>
  129. <div class="stat-icon stat-icon-red">
  130. <i class="el-icon-warning-outline"></i>
  131. </div>
  132. </div>
  133. </el-card>
  134. </el-col>
  135. </el-row>
  136. <!-- 趋势图表 -->
  137. <el-row :gutter="20" class="mb-20">
  138. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  139. <el-card>
  140. <div slot="header">
  141. <span><i class="el-icon-data-line"></i> 平均价格变化趋势</span>
  142. <span class="header-desc">按时间段统计</span>
  143. </div>
  144. <div ref="priceTrendChart" style="height: 400px"></div>
  145. </el-card>
  146. </el-col>
  147. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  148. <el-card>
  149. <div slot="header">
  150. <span><i class="el-icon-data-line"></i> 平均促销力度变化趋势</span>
  151. <span class="header-desc">按时间段统计</span>
  152. </div>
  153. <div ref="promotionTrendChart" style="height: 400px"></div>
  154. </el-card>
  155. </el-col>
  156. </el-row>
  157. <!-- 销量与异常检测图表 -->
  158. <el-row :gutter="20" class="mb-20">
  159. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  160. <el-card>
  161. <div slot="header">
  162. <span><i class="el-icon-s-order"></i> 销量趋势</span>
  163. <span class="header-desc">按时间段统计</span>
  164. </div>
  165. <div ref="salesTrendChart" style="height: 400px"></div>
  166. </el-card>
  167. </el-col>
  168. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  169. <el-card>
  170. <div slot="header">
  171. <span><i class="el-icon-warning"></i> 异常数据检测</span>
  172. <span class="header-desc">异常数据分布</span>
  173. </div>
  174. <div ref="anomalyDetectionChart" style="height: 400px"></div>
  175. </el-card>
  176. </el-col>
  177. </el-row>
  178. <!-- 异常数据详情 -->
  179. <el-card v-if="results.anomalies && results.anomalies.anomaly_count > 0" class="mb-20">
  180. <div slot="header">
  181. <span><i class="el-icon-warning-outline"></i> 异常数据详情</span>
  182. <span class="header-desc">共检测到 {{ results.anomalies.anomaly_count }} 个异常,异常率 {{ results.anomalies.anomaly_rate.toFixed(2) }}%</span>
  183. </div>
  184. <el-table :data="anomalyData" style="width: 100%">
  185. <el-table-column prop="date" label="日期" width="120">
  186. <template slot-scope="scope">
  187. {{ scope.row.date || 'N/A' }}
  188. </template>
  189. </el-table-column>
  190. <el-table-column prop="sku" label="SKU" width="180">
  191. <template slot-scope="scope">
  192. {{ scope.row.sku || 'N/A' }}
  193. </template>
  194. </el-table-column>
  195. <el-table-column prop="type" label="异常类型" width="120">
  196. <template slot-scope="scope">
  197. <el-tag :type="getAnomalyTypeTag(scope.row.type)">
  198. {{ getAnomalyTypeName(scope.row.type) }}
  199. </el-tag>
  200. </template>
  201. </el-table-column>
  202. <el-table-column prop="reason" label="异常原因" min-width="300">
  203. <template slot-scope="scope">
  204. <span class="anomaly-reason">{{ scope.row.reason }}</span>
  205. </template>
  206. </el-table-column>
  207. <el-table-column prop="value" label="实际值" width="100">
  208. <template slot-scope="scope">
  209. {{ scope.row.value.toFixed(2) }}
  210. </template>
  211. </el-table-column>
  212. <el-table-column prop="expected" label="预期值" width="100">
  213. <template slot-scope="scope">
  214. {{ scope.row.expected.toFixed(2) }}
  215. </template>
  216. </el-table-column>
  217. <el-table-column prop="deviation" label="偏差程度" width="100">
  218. <template slot-scope="scope">
  219. <el-progress
  220. :percentage="Math.min(scope.row.deviation * 20, 100)"
  221. :color="getDeviationColor(scope.row.deviation)"
  222. :stroke-width="10"
  223. />
  224. <span class="deviation-value">{{ scope.row.deviation.toFixed(2) }}</span>
  225. </template>
  226. </el-table-column>
  227. </el-table>
  228. </el-card>
  229. </div>
  230. </template>
  231. <script>
  232. import { uploadAndAnalyzeSales, getSalesResults } from '@/api/sales'
  233. import { getToken } from '@/utils/auth'
  234. import * as echarts from 'echarts'
  235. require('echarts/theme/macarons')
  236. export default {
  237. name: 'SalesOverview',
  238. data() {
  239. return {
  240. // 图表实例
  241. priceTrendChart: null,
  242. promotionTrendChart: null,
  243. salesTrendChart: null,
  244. anomalyDetectionChart: null,
  245. // 数据
  246. results: {},
  247. // 计算属性数据
  248. totalSales: 0,
  249. salesGrowthRate: 0,
  250. avgPrice: 0,
  251. priceChange: 0,
  252. avgPromotion: 0,
  253. promotionChange: 0,
  254. anomalyDetectionRate: 0,
  255. // 趋势数据
  256. timeSeries: [],
  257. priceSeries: [],
  258. promotionSeries: [],
  259. salesSeries: [],
  260. anomalySeries: [],
  261. // 视图选择相关
  262. selectedView: 'overall',
  263. selectedCategory: '',
  264. selectedSku: '',
  265. // 文件上传相关
  266. upload: {
  267. // 是否显示弹出层
  268. open: false,
  269. // 弹出层标题
  270. title: '',
  271. // 是否禁用上传
  272. isUploading: false,
  273. // 是否更新已经存在的文件
  274. updateSupport: 0,
  275. // 设置上传的请求头部
  276. headers: { Authorization: 'Bearer ' + getToken() },
  277. // 上传的地址
  278. url: process.env.VUE_APP_BASE_API + '/statistics/sales/upload'
  279. }
  280. }
  281. },
  282. mounted() {
  283. this.$nextTick(() => {
  284. this.initCharts()
  285. })
  286. // 监听窗口大小变化
  287. window.addEventListener('resize', this.handleResize)
  288. },
  289. beforeDestroy() {
  290. // 销毁图表实例
  291. if (this.priceTrendChart) {
  292. this.priceTrendChart.dispose()
  293. }
  294. if (this.promotionTrendChart) {
  295. this.promotionTrendChart.dispose()
  296. }
  297. if (this.salesTrendChart) {
  298. this.salesTrendChart.dispose()
  299. }
  300. if (this.anomalyDetectionChart) {
  301. this.anomalyDetectionChart.dispose()
  302. }
  303. window.removeEventListener('resize', this.handleResize)
  304. },
  305. methods: {
  306. /** 文件上传前的校验 */
  307. beforeUpload(file) {
  308. const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
  309. file.type === 'application/vnd.ms-excel' ||
  310. file.type === 'text/csv' ||
  311. file.name.endsWith('.xlsx') ||
  312. file.name.endsWith('.xls') ||
  313. file.name.endsWith('.csv')
  314. const isLt300M = file.size / 1024 / 1024 < 300
  315. if (!isExcel) {
  316. this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
  317. return false
  318. }
  319. if (!isLt300M) {
  320. this.$modal.msgError('上传文件大小不能超过 300MB!')
  321. return false
  322. }
  323. return true
  324. },
  325. /** 文件上传中处理 */
  326. handleFileUploadProgress(event, file, fileList) {
  327. this.upload.isUploading = true
  328. },
  329. /** 自定义上传方法 */
  330. customUpload(options) {
  331. console.log('customUpload called')
  332. console.log('options:', options)
  333. console.log('options.file:', options.file)
  334. if (!options.file) {
  335. console.error('No file in options')
  336. this.$modal.msgError('请选择要上传的文件')
  337. options.onError(new Error('No file to upload'))
  338. return
  339. }
  340. console.log('options.file.raw:', options.file.raw)
  341. const file = options.file.raw || options.file
  342. console.log('file to upload:', file)
  343. if (!file) {
  344. console.error('No file to upload')
  345. this.$modal.msgError('请选择要上传的文件')
  346. options.onError(new Error('No file to upload'))
  347. return
  348. }
  349. console.log('Starting file upload to:', '/statistics/sales/upload')
  350. this.upload.isUploading = true
  351. uploadAndAnalyzeSales(file).then(response => {
  352. console.log('uploadAndAnalyzeSales response:', response)
  353. this.upload.isUploading = false
  354. if (response && response.code === 200) {
  355. this.$modal.msgSuccess('文件上传并分析成功')
  356. // response.data 就是分析结果
  357. this.results = response.data || {}
  358. this.calculateMetrics()
  359. this.$nextTick(() => {
  360. this.renderCharts()
  361. })
  362. options.onSuccess(response)
  363. } else {
  364. console.error('Upload failed with response:', response)
  365. this.$modal.msgError(response.msg || '分析失败')
  366. options.onError(new Error(response.msg || '分析失败'))
  367. }
  368. // 重置上传组件
  369. this.$refs.upload.clearFiles()
  370. }).catch(error => {
  371. console.error('uploadAndAnalyzeSales error:', error)
  372. this.upload.isUploading = false
  373. const errorMsg = error.response?.data?.msg || error.message || '文件上传失败,请重试'
  374. this.$modal.msgError(errorMsg)
  375. options.onError(error)
  376. })
  377. },
  378. /** 提交上传文件 */
  379. submitUpload() {
  380. const fileList = this.$refs.upload.uploadFiles
  381. if (!fileList || fileList.length === 0) {
  382. this.$modal.msgError('请选择要上传的文件')
  383. return
  384. }
  385. this.$refs.upload.submit()
  386. },
  387. /** 重置上传 */
  388. resetUpload() {
  389. this.$refs.upload.clearFiles()
  390. },
  391. /** 获取销售分析结果 */
  392. getList() {
  393. getSalesResults().then(response => {
  394. if (response.code === 200 && response.data) {
  395. // response.data 就是分析结果
  396. this.results = response.data || {}
  397. this.calculateMetrics()
  398. this.$nextTick(() => {
  399. this.renderCharts()
  400. })
  401. }
  402. }).catch(() => {
  403. // 如果没有数据,不显示错误,只是不显示图表
  404. this.results = {}
  405. })
  406. },
  407. /** 计算关键指标 */
  408. calculateMetrics() {
  409. if (!this.results.summary) {
  410. return
  411. }
  412. // 根据选择的视图计算指标
  413. switch (this.selectedView) {
  414. case 'overall':
  415. this.calculateOverallMetrics()
  416. break
  417. case 'category':
  418. this.calculateCategoryMetrics()
  419. break
  420. case 'sku':
  421. this.calculateSkuMetrics()
  422. break
  423. default:
  424. this.calculateOverallMetrics()
  425. }
  426. },
  427. /** 计算总体指标 */
  428. calculateOverallMetrics() {
  429. if (this.results.summary) {
  430. this.totalSales = this.results.summary.total_quantity || 0
  431. this.avgPrice = this.results.summary.total_revenue / this.results.summary.total_quantity || 0
  432. // 模拟增长率和变化率数据
  433. this.salesGrowthRate = 12.5
  434. this.priceChange = -2.3
  435. this.avgPromotion = 15.8
  436. this.promotionChange = 3.2
  437. this.anomalyDetectionRate = 5.7
  438. // 模拟趋势数据
  439. this.timeSeries = ['1月', '2月', '3月', '4月', '5月', '6月']
  440. this.priceSeries = [95.2, 92.8, 90.5, 88.9, 90.2, 89.6]
  441. this.promotionSeries = [12.5, 13.2, 14.8, 15.5, 16.2, 15.8]
  442. this.salesSeries = [8500, 9200, 10500, 11200, 11800, 12580]
  443. this.anomalySeries = [4.2, 3.8, 5.1, 6.5, 7.2, 5.7]
  444. }
  445. },
  446. /** 计算品类指标 */
  447. calculateCategoryMetrics() {
  448. if (this.selectedCategory && this.results.categories && this.results.categories[this.selectedCategory]) {
  449. const categoryData = this.results.categories[this.selectedCategory]
  450. this.totalSales = categoryData.total_quantity || 0
  451. this.avgPrice = categoryData.avg_price || 0
  452. // 模拟增长率和变化率数据
  453. this.salesGrowthRate = 15.2
  454. this.priceChange = 1.8
  455. this.avgPromotion = 18.5
  456. this.promotionChange = 2.5
  457. this.anomalyDetectionRate = 4.8
  458. // 使用品类的趋势数据
  459. this.timeSeries = categoryData.date_series || ['1月', '2月', '3月', '4月', '5月', '6月']
  460. this.priceSeries = categoryData.price_series || [90.2, 91.5, 92.8, 93.1, 92.5, 91.8]
  461. this.salesSeries = categoryData.quantity_series || [7500, 8200, 9100, 9800, 10500, 11200]
  462. this.promotionSeries = [16.5, 17.2, 18.1, 19.0, 18.8, 18.5]
  463. this.anomalySeries = [3.8, 4.2, 4.5, 5.1, 4.9, 4.8]
  464. } else {
  465. // 默认选择第一个品类
  466. if (this.results.category_list && this.results.category_list.length > 0) {
  467. this.selectedCategory = this.results.category_list[0]
  468. this.calculateCategoryMetrics()
  469. } else {
  470. this.calculateOverallMetrics()
  471. }
  472. }
  473. },
  474. /** 计算SKU指标 */
  475. calculateSkuMetrics() {
  476. if (this.selectedSku && this.results.data && this.results.data[this.selectedSku]) {
  477. const skuData = this.results.data[this.selectedSku]
  478. this.totalSales = skuData.total_quantity || 0
  479. this.avgPrice = skuData.avg_price || 0
  480. // 模拟增长率和变化率数据
  481. this.salesGrowthRate = 22.8
  482. this.priceChange = -0.5
  483. this.avgPromotion = 22.5
  484. this.promotionChange = 3.8
  485. this.anomalyDetectionRate = 3.2
  486. // 使用SKU的趋势数据
  487. this.timeSeries = skuData.date_series || ['1月', '2月', '3月', '4月', '5月', '6月']
  488. this.priceSeries = skuData.price_series || [85.2, 84.8, 85.1, 84.9, 84.7, 84.5]
  489. this.salesSeries = skuData.quantity_series || [1200, 1350, 1500, 1650, 1800, 1950]
  490. this.promotionSeries = [20.5, 21.2, 21.8, 22.5, 23.0, 22.5]
  491. this.anomalySeries = [2.8, 3.1, 3.3, 3.5, 3.2, 3.0]
  492. } else {
  493. // 默认选择第一个SKU
  494. if (this.results.sku_list && this.results.sku_list.length > 0) {
  495. this.selectedSku = this.results.sku_list[0]
  496. this.calculateSkuMetrics()
  497. } else {
  498. this.calculateOverallMetrics()
  499. }
  500. }
  501. },
  502. /** 处理视图变化 */
  503. handleViewChange() {
  504. this.calculateMetrics()
  505. this.renderCharts()
  506. },
  507. /** 处理品类变化 */
  508. handleCategoryChange() {
  509. // Reset selected SKU and select first available in new category
  510. const availableSkus = this.getAvailableSkus()
  511. if (availableSkus.length > 0) {
  512. this.selectedSku = availableSkus[0]
  513. } else {
  514. this.selectedSku = ''
  515. }
  516. this.calculateMetrics()
  517. this.renderCharts()
  518. },
  519. /** 处理SKU变化 */
  520. handleSkuChange() {
  521. this.calculateMetrics()
  522. this.renderCharts()
  523. },
  524. /** 获取可用的SKU列表 */
  525. getAvailableSkus() {
  526. if (this.selectedView === 'category' && this.selectedCategory && this.results.category_skus) {
  527. return this.results.category_skus[this.selectedCategory] || []
  528. } else {
  529. return this.results.sku_list || []
  530. }
  531. },
  532. /** 获取异常类型标签 */
  533. getAnomalyTypeTag(type) {
  534. switch (type) {
  535. case 'quantity_spike':
  536. case 'sku_price_spike':
  537. return 'warning'
  538. case 'quantity_drop':
  539. case 'sku_price_drop':
  540. return 'danger'
  541. case 'price_spike':
  542. return 'info'
  543. case 'price_drop':
  544. return 'success'
  545. default:
  546. return 'primary'
  547. }
  548. },
  549. /** 获取异常类型名称 */
  550. getAnomalyTypeName(type) {
  551. switch (type) {
  552. case 'quantity_spike':
  553. return '销量激增'
  554. case 'quantity_drop':
  555. return '销量骤降'
  556. case 'price_spike':
  557. return '价格上涨'
  558. case 'price_drop':
  559. return '价格下降'
  560. case 'sku_price_spike':
  561. return 'SKU涨价'
  562. case 'sku_price_drop':
  563. return 'SKU降价'
  564. default:
  565. return '异常'
  566. }
  567. },
  568. /** 获取偏差程度颜色 */
  569. getDeviationColor(deviation) {
  570. if (deviation > 3) {
  571. return '#ff4d4f'
  572. } else if (deviation > 2.5) {
  573. return '#fa8c16'
  574. } else if (deviation > 2) {
  575. return '#faad14'
  576. } else {
  577. return '#52c41a'
  578. }
  579. },
  580. /** 初始化图表 */
  581. initCharts() {
  582. if (this.$refs.priceTrendChart) {
  583. this.priceTrendChart = echarts.init(this.$refs.priceTrendChart, 'macarons')
  584. }
  585. if (this.$refs.promotionTrendChart) {
  586. this.promotionTrendChart = echarts.init(this.$refs.promotionTrendChart, 'macarons')
  587. }
  588. if (this.$refs.salesTrendChart) {
  589. this.salesTrendChart = echarts.init(this.$refs.salesTrendChart, 'macarons')
  590. }
  591. if (this.$refs.anomalyDetectionChart) {
  592. this.anomalyDetectionChart = echarts.init(this.$refs.anomalyDetectionChart, 'macarons')
  593. }
  594. },
  595. /** 渲染所有图表 */
  596. renderCharts() {
  597. // 1. 平均价格变化趋势
  598. this.renderPriceTrend()
  599. // 2. 平均促销力度变化趋势
  600. this.renderPromotionTrend()
  601. // 3. 销量趋势
  602. this.renderSalesTrend()
  603. // 4. 异常数据检测
  604. this.renderAnomalyDetection()
  605. },
  606. /** 渲染平均价格变化趋势图表 */
  607. renderPriceTrend() {
  608. const option = {
  609. tooltip: {
  610. trigger: 'axis',
  611. axisPointer: {
  612. type: 'cross',
  613. label: {
  614. backgroundColor: '#6a7985'
  615. }
  616. }
  617. },
  618. legend: {
  619. data: ['平均价格']
  620. },
  621. grid: {
  622. left: '3%',
  623. right: '4%',
  624. bottom: '3%',
  625. containLabel: true
  626. },
  627. xAxis: {
  628. type: 'category',
  629. boundaryGap: false,
  630. data: this.timeSeries
  631. },
  632. yAxis: {
  633. type: 'value',
  634. name: '价格 (¥)'
  635. },
  636. series: [
  637. {
  638. name: '平均价格',
  639. type: 'line',
  640. stack: 'Total',
  641. smooth: true,
  642. lineStyle: {
  643. width: 3
  644. },
  645. areaStyle: {
  646. opacity: 0.3
  647. },
  648. data: this.priceSeries,
  649. itemStyle: {
  650. color: '#10b981'
  651. }
  652. }
  653. ]
  654. }
  655. if (this.priceTrendChart) {
  656. this.priceTrendChart.setOption(option)
  657. }
  658. },
  659. /** 渲染平均促销力度变化趋势图表 */
  660. renderPromotionTrend() {
  661. const option = {
  662. tooltip: {
  663. trigger: 'axis',
  664. axisPointer: {
  665. type: 'cross',
  666. label: {
  667. backgroundColor: '#6a7985'
  668. }
  669. }
  670. },
  671. legend: {
  672. data: ['平均促销力度']
  673. },
  674. grid: {
  675. left: '3%',
  676. right: '4%',
  677. bottom: '3%',
  678. containLabel: true
  679. },
  680. xAxis: {
  681. type: 'category',
  682. boundaryGap: false,
  683. data: this.timeSeries
  684. },
  685. yAxis: {
  686. type: 'value',
  687. name: '促销力度 (%)'
  688. },
  689. series: [
  690. {
  691. name: '平均促销力度',
  692. type: 'line',
  693. stack: 'Total',
  694. smooth: true,
  695. lineStyle: {
  696. width: 3
  697. },
  698. areaStyle: {
  699. opacity: 0.3
  700. },
  701. data: this.promotionSeries,
  702. itemStyle: {
  703. color: '#f59e0b'
  704. }
  705. }
  706. ]
  707. }
  708. if (this.promotionTrendChart) {
  709. this.promotionTrendChart.setOption(option)
  710. }
  711. },
  712. /** 渲染销量趋势图表 */
  713. renderSalesTrend() {
  714. const option = {
  715. tooltip: {
  716. trigger: 'axis',
  717. axisPointer: {
  718. type: 'cross',
  719. label: {
  720. backgroundColor: '#6a7985'
  721. }
  722. }
  723. },
  724. legend: {
  725. data: ['销量']
  726. },
  727. grid: {
  728. left: '3%',
  729. right: '4%',
  730. bottom: '3%',
  731. containLabel: true
  732. },
  733. xAxis: {
  734. type: 'category',
  735. boundaryGap: false,
  736. data: this.timeSeries
  737. },
  738. yAxis: {
  739. type: 'value',
  740. name: '销量'
  741. },
  742. series: [
  743. {
  744. name: '销量',
  745. type: 'line',
  746. stack: 'Total',
  747. smooth: true,
  748. lineStyle: {
  749. width: 3
  750. },
  751. areaStyle: {
  752. opacity: 0.3
  753. },
  754. data: this.salesSeries,
  755. itemStyle: {
  756. color: '#3b82f6'
  757. }
  758. }
  759. ]
  760. }
  761. if (this.salesTrendChart) {
  762. this.salesTrendChart.setOption(option)
  763. }
  764. },
  765. /** 渲染异常数据检测图表 */
  766. renderAnomalyDetection() {
  767. const option = {
  768. tooltip: {
  769. trigger: 'axis',
  770. axisPointer: {
  771. type: 'shadow'
  772. }
  773. },
  774. grid: {
  775. left: '3%',
  776. right: '4%',
  777. bottom: '3%',
  778. containLabel: true
  779. },
  780. xAxis: {
  781. type: 'category',
  782. data: this.timeSeries
  783. },
  784. yAxis: {
  785. type: 'value',
  786. name: '异常检测率 (%)'
  787. },
  788. series: [
  789. {
  790. name: '异常检测率',
  791. type: 'bar',
  792. data: this.anomalySeries,
  793. itemStyle: {
  794. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  795. {
  796. offset: 0,
  797. color: '#ef4444'
  798. },
  799. {
  800. offset: 1,
  801. color: '#fca5a5'
  802. }
  803. ])
  804. },
  805. label: {
  806. show: true,
  807. position: 'top',
  808. formatter: '{c}%'
  809. }
  810. }
  811. ]
  812. }
  813. if (this.anomalyDetectionChart) {
  814. this.anomalyDetectionChart.setOption(option)
  815. }
  816. },
  817. /** 窗口大小变化处理 */
  818. handleResize() {
  819. if (this.priceTrendChart) {
  820. this.priceTrendChart.resize()
  821. }
  822. if (this.promotionTrendChart) {
  823. this.promotionTrendChart.resize()
  824. }
  825. if (this.salesTrendChart) {
  826. this.salesTrendChart.resize()
  827. }
  828. if (this.anomalyDetectionChart) {
  829. this.anomalyDetectionChart.resize()
  830. }
  831. }
  832. },
  833. computed: {
  834. /** 异常数据列表 */
  835. anomalyData() {
  836. if (this.results.anomalies && this.results.anomalies.anomalies) {
  837. return this.results.anomalies.anomalies.sort((a, b) => b.deviation - a.deviation)
  838. }
  839. return []
  840. }
  841. }
  842. }
  843. </script>
  844. <style scoped lang="scss">
  845. .app-container {
  846. padding: 20px;
  847. }
  848. .page-header {
  849. margin-bottom: 20px;
  850. h2 {
  851. font-size: 24px;
  852. font-weight: 600;
  853. color: #303133;
  854. margin-bottom: 8px;
  855. i {
  856. margin-right: 8px;
  857. color: #409EFF;
  858. }
  859. }
  860. .page-desc {
  861. color: #909399;
  862. font-size: 14px;
  863. margin: 0;
  864. }
  865. }
  866. .mb-20 {
  867. margin-bottom: 20px;
  868. }
  869. .stat-card {
  870. .stat-content {
  871. display: flex;
  872. justify-content: space-between;
  873. align-items: flex-start;
  874. .stat-info {
  875. flex: 1;
  876. .stat-label {
  877. font-size: 12px;
  878. color: #909399;
  879. margin: 0 0 8px 0;
  880. text-transform: uppercase;
  881. letter-spacing: 0.5px;
  882. }
  883. .stat-value {
  884. font-size: 28px;
  885. font-weight: bold;
  886. color: #303133;
  887. margin: 0 0 8px 0;
  888. }
  889. .stat-desc {
  890. font-size: 12px;
  891. color: #909399;
  892. margin: 0;
  893. &.stat-desc-success {
  894. color: #67C23A;
  895. }
  896. &.stat-desc-error {
  897. color: #F56C6C;
  898. }
  899. }
  900. }
  901. .stat-icon {
  902. width: 48px;
  903. height: 48px;
  904. border-radius: 50%;
  905. display: flex;
  906. align-items: center;
  907. justify-content: center;
  908. font-size: 20px;
  909. &.stat-icon-purple {
  910. background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
  911. color: #6366f1;
  912. }
  913. &.stat-icon-blue {
  914. background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
  915. color: #3b82f6;
  916. }
  917. &.stat-icon-teal {
  918. background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
  919. color: #14b8a6;
  920. }
  921. &.stat-icon-green {
  922. background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
  923. color: #10b981;
  924. }
  925. &.stat-icon-yellow {
  926. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  927. color: #f59e0b;
  928. }
  929. &.stat-icon-red {
  930. background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
  931. color: #ef4444;
  932. }
  933. }
  934. }
  935. }
  936. ::v-deep .el-card__header {
  937. display: flex;
  938. justify-content: space-between;
  939. align-items: center;
  940. .header-desc {
  941. font-size: 12px;
  942. color: #909399;
  943. font-weight: normal;
  944. }
  945. }
  946. .view-selector {
  947. display: flex;
  948. align-items: center;
  949. flex-wrap: wrap;
  950. gap: 10px;
  951. .el-radio-group {
  952. display: flex;
  953. align-items: center;
  954. }
  955. .el-select {
  956. margin-top: 5px;
  957. }
  958. }
  959. @media screen and (max-width: 768px) {
  960. .view-selector {
  961. flex-direction: column;
  962. align-items: flex-start;
  963. .el-select {
  964. width: 100% !important;
  965. margin-left: 0 !important;
  966. }
  967. }
  968. }
  969. /* Anomaly styles */
  970. .anomaly-reason {
  971. line-height: 1.4;
  972. color: #303133;
  973. }
  974. .deviation-value {
  975. display: block;
  976. text-align: center;
  977. font-size: 12px;
  978. color: #909399;
  979. margin-top: 4px;
  980. }
  981. ::v-deep .el-table .cell {
  982. padding: 12px 10px;
  983. }
  984. ::v-deep .el-table__row:hover {
  985. background-color: #f5f7fa !important;
  986. }
  987. ::v-deep .el-tag {
  988. margin-right: 0;
  989. }
  990. /* Anomaly type tag styles */
  991. ::v-deep .el-tag--warning {
  992. background-color: #fff7e6;
  993. border-color: #ffd591;
  994. color: #fa8c16;
  995. }
  996. ::v-deep .el-tag--danger {
  997. background-color: #fff1f0;
  998. border-color: #ffccc7;
  999. color: #f5222d;
  1000. }
  1001. ::v-deep .el-tag--info {
  1002. background-color: #e6f7ff;
  1003. border-color: #91d5ff;
  1004. color: #1890ff;
  1005. }
  1006. ::v-deep .el-tag--success {
  1007. background-color: #f6ffed;
  1008. border-color: #b7eb8f;
  1009. color: #52c41a;
  1010. }
  1011. /* Progress bar styles */
  1012. ::v-deep .el-progress {
  1013. margin-bottom: 4px;
  1014. }
  1015. ::v-deep .el-progress-bar__outer {
  1016. background-color: #f0f0f0;
  1017. }
  1018. ::v-deep .el-progress-bar__inner {
  1019. border-radius: 5px;
  1020. }
  1021. </style>