index.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848
  1. <template>
  2. <div class="app-container">
  3. <!-- 页面标题 -->
  4. <div class="page-header">
  5. <h2><i class="el-icon-data-analysis"></i> 促销效果分析</h2>
  6. <p class="page-desc">深入分析促销活动对销售的影响,评估促销效果</p>
  7. </div>
  8. <div class="upload-toolbar">
  9. <div class="toolbar-left">
  10. <el-upload
  11. ref="upload"
  12. class="toolbar-upload"
  13. :limit="1"
  14. accept=".xlsx,.xls,.csv"
  15. :http-request="customUpload"
  16. :disabled="upload.isUploading"
  17. :on-change="handleFileChange"
  18. :before-upload="beforeUpload"
  19. :auto-upload="false"
  20. :show-file-list="false"
  21. >
  22. <el-button plain>上传文件</el-button>
  23. </el-upload>
  24. <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
  25. <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
  26. </div>
  27. <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
  28. <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
  29. <div class="toolbar-status muted" v-else>未上传</div>
  30. </div>
  31. <!-- 关键指标卡片 -->
  32. <el-row :gutter="20" class="mb-20">
  33. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  34. <el-card class="stat-card">
  35. <div class="stat-content">
  36. <div class="stat-info">
  37. <p class="stat-label">订单总数</p>
  38. <p class="stat-value">{{ summary.total_orders || 0 }}</p>
  39. <p class="stat-desc">分析期间</p>
  40. </div>
  41. <div class="stat-icon stat-icon-purple">
  42. <i class="el-icon-s-order"></i>
  43. </div>
  44. </div>
  45. </el-card>
  46. </el-col>
  47. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  48. <el-card class="stat-card">
  49. <div class="stat-content">
  50. <div class="stat-info">
  51. <p class="stat-label">促销订单占比</p>
  52. <p class="stat-value">{{ summary.promotional_ratio ? summary.promotional_ratio.toFixed(1) : 0 }}%</p>
  53. <p class="stat-desc">促销订单数: {{ summary.promotional_orders || 0 }}</p>
  54. </div>
  55. <div class="stat-icon stat-icon-blue">
  56. <i class="el-icon-s-marketing"></i>
  57. </div>
  58. </div>
  59. </el-card>
  60. </el-col>
  61. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  62. <el-card class="stat-card">
  63. <div class="stat-content">
  64. <div class="stat-info">
  65. <p class="stat-label">促销销售额</p>
  66. <p class="stat-value">{{ summary.promo_revenue || 0 }}</p>
  67. <p class="stat-desc">非促销: {{ summary.non_promo_revenue || 0 }}</p>
  68. </div>
  69. <div class="stat-icon stat-icon-teal">
  70. <i class="el-icon-s-finance"></i>
  71. </div>
  72. </div>
  73. </el-card>
  74. </el-col>
  75. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  76. <el-card class="stat-card">
  77. <div class="stat-content">
  78. <div class="stat-info">
  79. <p class="stat-label">促销销量</p>
  80. <p class="stat-value">{{ summary.promo_quantity || 0 }}</p>
  81. <p class="stat-desc">非促销: {{ summary.non_promo_quantity || 0 }}</p>
  82. </div>
  83. <div class="stat-icon stat-icon-green">
  84. <i class="el-icon-s-goods"></i>
  85. </div>
  86. </div>
  87. </el-card>
  88. </el-col>
  89. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  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">{{ summary.avg_promotion ? summary.avg_promotion.toFixed(1) : 0 }}%</p>
  95. <p class="stat-desc">折扣幅度</p>
  96. </div>
  97. <div class="stat-icon stat-icon-yellow">
  98. <i class="el-icon-s-printer"></i>
  99. </div>
  100. </div>
  101. </el-card>
  102. </el-col>
  103. <el-col :xs="24" :sm="12" :md="8" :lg="4">
  104. <el-card class="stat-card">
  105. <div class="stat-content">
  106. <div class="stat-info">
  107. <p class="stat-label">促销效果评分</p>
  108. <p class="stat-value">{{ effectEvaluation.score ? effectEvaluation.score.toFixed(1) : 0 }}</p>
  109. <p class="stat-desc stat-desc-success">{{ effectEvaluation.level || '需改进' }}</p>
  110. </div>
  111. <div class="stat-icon stat-icon-red">
  112. <i class="el-icon-star-on"></i>
  113. </div>
  114. </div>
  115. </el-card>
  116. </el-col>
  117. </el-row>
  118. <!-- 促销类型分析与时间趋势 -->
  119. <el-row :gutter="20" class="mb-20">
  120. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  121. <el-card>
  122. <div slot="header">
  123. <span><i class="el-icon-pie-chart"></i> 促销类型分布</span>
  124. <span class="header-desc">按促销力度划分</span>
  125. </div>
  126. <div ref="promotionTypeChart" style="height: 400px"></div>
  127. </el-card>
  128. </el-col>
  129. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  130. <el-card>
  131. <div slot="header">
  132. <span><i class="el-icon-data-line"></i> 促销效果时间趋势</span>
  133. <span class="header-desc">销量与销售额对比</span>
  134. </div>
  135. <div ref="timeTrendChart" style="height: 400px"></div>
  136. </el-card>
  137. </el-col>
  138. </el-row>
  139. <!-- 品类分析与SKU分析 -->
  140. <el-row :gutter="20" class="mb-20">
  141. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  142. <el-card>
  143. <div slot="header">
  144. <span><i class="el-icon-s-grid"></i> 品类促销效果</span>
  145. <span class="header-desc">销量提升率</span>
  146. </div>
  147. <div ref="categoryEffectChart" style="height: 400px"></div>
  148. </el-card>
  149. </el-col>
  150. <el-col :xs="24" :sm="24" :md="12" :lg="12">
  151. <el-card>
  152. <div slot="header">
  153. <span><i class="el-icon-s-operation"></i> 促销效果评估</span>
  154. <span class="header-desc">综合评分与各项指标</span>
  155. </div>
  156. <div ref="effectEvaluationChart" style="height: 400px"></div>
  157. </el-card>
  158. </el-col>
  159. </el-row>
  160. </div>
  161. </template>
  162. <script>
  163. import { analyzeSaleEffectWithFile, getSaleEffectResults } from '@/api/client'
  164. import { getToken } from '@/utils/auth'
  165. import * as echarts from 'echarts'
  166. require('echarts/theme/macarons')
  167. export default {
  168. name: 'SaleEffectAnalysis',
  169. data() {
  170. return {
  171. // 图表实例
  172. promotionTypeChart: null,
  173. timeTrendChart: null,
  174. categoryEffectChart: null,
  175. effectEvaluationChart: null,
  176. // 数据
  177. results: {},
  178. // 计算属性数据
  179. summary: {},
  180. effectEvaluation: {},
  181. // 文件上传相关
  182. upload: {
  183. // 是否显示弹出层
  184. open: false,
  185. // 弹出层标题
  186. title: '',
  187. // 是否禁用上传
  188. isUploading: false,
  189. // 是否更新已经存在的文件
  190. updateSupport: 0,
  191. // 设置上传的请求头部
  192. headers: { Authorization: 'Bearer ' + getToken() },
  193. // 上传的地址
  194. url: process.env.VUE_APP_PYTHON_API + '/api/sale-effect/upload',
  195. // 文件名称
  196. fileName: '',
  197. // 已选择文件名称
  198. pendingFileName: '',
  199. // 是否忽略文件选择改变
  200. ignoreFileChange: false,
  201. }
  202. }
  203. },
  204. computed: {
  205. hasResults() {
  206. return Object.keys(this.results || {}).length > 0
  207. }
  208. },
  209. mounted() {
  210. this.$nextTick(() => {
  211. this.initCharts()
  212. })
  213. // 监听窗口大小变化
  214. window.addEventListener('resize', this.handleResize)
  215. },
  216. beforeDestroy() {
  217. // 销毁图表实例
  218. if (this.promotionTypeChart) {
  219. this.promotionTypeChart.dispose()
  220. }
  221. if (this.timeTrendChart) {
  222. this.timeTrendChart.dispose()
  223. }
  224. if (this.categoryEffectChart) {
  225. this.categoryEffectChart.dispose()
  226. }
  227. if (this.effectEvaluationChart) {
  228. this.effectEvaluationChart.dispose()
  229. }
  230. window.removeEventListener('resize', this.handleResize)
  231. },
  232. methods: {
  233. /** 文件选择改变处理 */
  234. handleFileChange(file, fileList) {
  235. if (this.upload.ignoreChange) return
  236. if (!fileList || fileList.length === 0) return
  237. if (!file || !file.raw) return
  238. this.upload.pendingFileName = file.name
  239. this.upload.fileName = ''
  240. },
  241. /** 文件上传前的校验 */
  242. beforeUpload(file) {
  243. const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
  244. file.type === 'application/vnd.ms-excel' ||
  245. file.type === 'text/csv' ||
  246. file.name.endsWith('.xlsx') ||
  247. file.name.endsWith('.xls') ||
  248. file.name.endsWith('.csv')
  249. const isLt500M = file.size / 1024 / 1024 < 500
  250. if (!isExcel) {
  251. this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
  252. return false
  253. }
  254. if (!isLt500M) {
  255. this.$modal.msgError('上传文件大小不能超过 500MB!')
  256. return false
  257. }
  258. return true
  259. },
  260. /** 自定义上传方法 */
  261. customUpload(options) {
  262. const file = options.file
  263. this.upload.isUploading = true
  264. analyzeSaleEffectWithFile(file).then(response => {
  265. this.upload.isUploading = false
  266. if (response && response.success) {
  267. this.$modal.msgSuccess('文件上传并分析成功')
  268. this.upload.fileName = this.upload.pendingFileName || file.name
  269. this.upload.pendingFileName = ''
  270. this.results = response.data || {}
  271. this.updateMetrics()
  272. this.$nextTick(() => {
  273. this.renderCharts()
  274. })
  275. options.onSuccess(response)
  276. } else {
  277. const message = (response && response.message) || '分析失败'
  278. this.$modal.msgError(message)
  279. options.onError(new Error(message))
  280. }
  281. if (this.$refs.upload) {
  282. this.upload.ignoreFileChange = true
  283. this.$refs.upload.clearFiles()
  284. this.$nextTick(() => {
  285. this.upload.ignoreFileChange = false
  286. })
  287. }
  288. }).catch(error => {
  289. this.upload.isUploading = false
  290. const errorMsg = (error && error.response && error.response.data && error.response.data.message) || error.message || '文件上传失败,请重试'
  291. this.$modal.msgError(errorMsg)
  292. options.onError(error)
  293. })
  294. },
  295. /** 提交上传文件 */
  296. submitUpload() {
  297. const fileList = this.$refs.upload.uploadFiles
  298. if (!fileList || fileList.length === 0) {
  299. this.$modal.msgError('请选择要上传的文件')
  300. return
  301. }
  302. this.$refs.upload.submit()
  303. },
  304. /** 获取促销效果分析结果 */
  305. getList() {
  306. getSaleEffectResults().then(response => {
  307. if (response && response.success && response.data) {
  308. this.results = response.data || {}
  309. this.updateMetrics()
  310. this.$nextTick(() => {
  311. this.renderCharts()
  312. })
  313. }
  314. }).catch(() => {
  315. this.results = {}
  316. })
  317. },
  318. /** 更新指标 */
  319. updateMetrics() {
  320. this.summary = this.results.summary || {}
  321. this.effectEvaluation = this.results.effect_evaluation || {}
  322. },
  323. /** 初始化图表 */
  324. initCharts() {
  325. if (this.$refs.promotionTypeChart) {
  326. this.promotionTypeChart = echarts.init(this.$refs.promotionTypeChart, 'macarons')
  327. }
  328. if (this.$refs.timeTrendChart) {
  329. this.timeTrendChart = echarts.init(this.$refs.timeTrendChart, 'macarons')
  330. }
  331. if (this.$refs.categoryEffectChart) {
  332. this.categoryEffectChart = echarts.init(this.$refs.categoryEffectChart, 'macarons')
  333. }
  334. if (this.$refs.effectEvaluationChart) {
  335. this.effectEvaluationChart = echarts.init(this.$refs.effectEvaluationChart, 'macarons')
  336. }
  337. },
  338. /** 渲染所有图表 */
  339. renderCharts() {
  340. // 1. 促销类型分布
  341. this.renderPromotionTypeChart()
  342. // 2. 时间趋势
  343. this.renderTimeTrendChart()
  344. // 3. 品类效果
  345. this.renderCategoryEffectChart()
  346. // 4. 效果评估
  347. this.renderEffectEvaluationChart()
  348. },
  349. /** 渲染促销类型分布 */
  350. renderPromotionTypeChart() {
  351. const promotionTypes = this.results.promotion_types || {}
  352. const types = promotionTypes.types || {}
  353. const typeList = promotionTypes.type_list || []
  354. const data = typeList.map(type => {
  355. const info = types[type] || {}
  356. return {
  357. name: type,
  358. value: info.order_count || 0
  359. }
  360. })
  361. const option = {
  362. tooltip: {
  363. trigger: 'item',
  364. formatter: '{a} <br/>{b}: {c} ({d}%)'
  365. },
  366. legend: {
  367. orient: 'vertical',
  368. left: 'left',
  369. data: typeList
  370. },
  371. series: [
  372. {
  373. name: '促销类型',
  374. type: 'pie',
  375. radius: ['40%', '70%'],
  376. avoidLabelOverlap: false,
  377. itemStyle: {
  378. borderRadius: 10,
  379. borderColor: '#fff',
  380. borderWidth: 2
  381. },
  382. label: {
  383. show: true,
  384. formatter: '{b}: {c}\n({d}%)'
  385. },
  386. emphasis: {
  387. label: {
  388. show: true,
  389. fontSize: 16,
  390. fontWeight: 'bold'
  391. }
  392. },
  393. data: data,
  394. color: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']
  395. }
  396. ]
  397. }
  398. if (this.promotionTypeChart) {
  399. this.promotionTypeChart.setOption(option)
  400. }
  401. },
  402. /** 渲染时间趋势 */
  403. renderTimeTrendChart() {
  404. const timeAnalysis = this.results.time_analysis || {}
  405. const dateSeries = timeAnalysis.date_series || []
  406. const promoQuantity = timeAnalysis.promo_quantity_series || []
  407. const nonPromoQuantity = timeAnalysis.non_promo_quantity_series || []
  408. const promoRevenue = timeAnalysis.promo_revenue_series || []
  409. const nonPromoRevenue = timeAnalysis.non_promo_revenue_series || []
  410. const option = {
  411. tooltip: {
  412. trigger: 'axis',
  413. axisPointer: {
  414. type: 'cross',
  415. label: {
  416. backgroundColor: '#6a7985'
  417. }
  418. }
  419. },
  420. legend: {
  421. data: ['促销销量', '非促销销量', '促销销售额', '非促销销售额']
  422. },
  423. grid: {
  424. left: '3%',
  425. right: '4%',
  426. bottom: '3%',
  427. containLabel: true
  428. },
  429. xAxis: {
  430. type: 'category',
  431. boundaryGap: false,
  432. data: dateSeries
  433. },
  434. yAxis: [
  435. {
  436. type: 'value',
  437. name: '销量',
  438. position: 'left'
  439. },
  440. {
  441. type: 'value',
  442. name: '销售额',
  443. position: 'right'
  444. }
  445. ],
  446. series: [
  447. {
  448. name: '促销销量',
  449. type: 'line',
  450. data: promoQuantity,
  451. itemStyle: {
  452. color: '#3b82f6'
  453. }
  454. },
  455. {
  456. name: '非促销销量',
  457. type: 'line',
  458. data: nonPromoQuantity,
  459. itemStyle: {
  460. color: '#94a3b8'
  461. }
  462. },
  463. {
  464. name: '促销销售额',
  465. type: 'line',
  466. yAxisIndex: 1,
  467. data: promoRevenue,
  468. itemStyle: {
  469. color: '#10b981'
  470. }
  471. },
  472. {
  473. name: '非促销销售额',
  474. type: 'line',
  475. yAxisIndex: 1,
  476. data: nonPromoRevenue,
  477. itemStyle: {
  478. color: '#f59e0b'
  479. }
  480. }
  481. ]
  482. }
  483. if (this.timeTrendChart) {
  484. this.timeTrendChart.setOption(option)
  485. }
  486. },
  487. /** 渲染品类效果 */
  488. renderCategoryEffectChart() {
  489. const categoryAnalysis = this.results.category_analysis || {}
  490. const categoryEffects = categoryAnalysis.category_effects || {}
  491. const categoryList = categoryAnalysis.category_list || []
  492. const categories = categoryList
  493. const quantityEffects = categories.map(category => {
  494. const effect = categoryEffects[category] || {}
  495. return effect.quantity_effect || 0
  496. })
  497. const revenueEffects = categories.map(category => {
  498. const effect = categoryEffects[category] || {}
  499. return effect.revenue_effect || 0
  500. })
  501. const option = {
  502. tooltip: {
  503. trigger: 'axis',
  504. axisPointer: {
  505. type: 'shadow'
  506. }
  507. },
  508. legend: {
  509. data: ['销量提升率', '销售额提升率']
  510. },
  511. grid: {
  512. left: '3%',
  513. right: '4%',
  514. bottom: '3%',
  515. containLabel: true
  516. },
  517. xAxis: {
  518. type: 'category',
  519. data: categories
  520. },
  521. yAxis: {
  522. type: 'value',
  523. name: '提升率(%)'
  524. },
  525. series: [
  526. {
  527. name: '销量提升率',
  528. type: 'bar',
  529. data: quantityEffects,
  530. itemStyle: {
  531. color: 'rgba(59,130,246,0.7)'
  532. }
  533. },
  534. {
  535. name: '销售额提升率',
  536. type: 'bar',
  537. data: revenueEffects,
  538. itemStyle: {
  539. color: 'rgba(16,185,129,0.7)'
  540. }
  541. }
  542. ]
  543. }
  544. if (this.categoryEffectChart) {
  545. this.categoryEffectChart.setOption(option)
  546. }
  547. },
  548. /** 渲染效果评估 */
  549. renderEffectEvaluationChart() {
  550. const evaluation = this.results.effect_evaluation || {}
  551. const data = [
  552. {
  553. name: '销量提升率',
  554. value: evaluation.quantity_lift || 0
  555. },
  556. {
  557. name: '销售额提升率',
  558. value: evaluation.revenue_lift || 0
  559. },
  560. {
  561. name: '促销占比',
  562. value: evaluation.promo_ratio || 0
  563. },
  564. {
  565. name: '平均促销力度',
  566. value: evaluation.avg_promotion || 0
  567. },
  568. {
  569. name: '综合评分',
  570. value: evaluation.score || 0
  571. }
  572. ]
  573. const option = {
  574. tooltip: {
  575. trigger: 'axis',
  576. axisPointer: {
  577. type: 'shadow'
  578. }
  579. },
  580. grid: {
  581. left: '3%',
  582. right: '4%',
  583. bottom: '3%',
  584. containLabel: true
  585. },
  586. xAxis: {
  587. type: 'value',
  588. name: '数值'
  589. },
  590. yAxis: {
  591. type: 'category',
  592. data: data.map(item => item.name)
  593. },
  594. series: [
  595. {
  596. name: '指标值',
  597. type: 'bar',
  598. data: data.map(item => item.value),
  599. itemStyle: {
  600. color: function(params) {
  601. const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']
  602. return colors[params.dataIndex % colors.length]
  603. }
  604. },
  605. label: {
  606. show: true,
  607. position: 'right',
  608. formatter: '{c}'
  609. }
  610. }
  611. ]
  612. }
  613. if (this.effectEvaluationChart) {
  614. this.effectEvaluationChart.setOption(option)
  615. }
  616. },
  617. /** 窗口大小变化处理 */
  618. handleResize() {
  619. if (this.promotionTypeChart) {
  620. this.promotionTypeChart.resize()
  621. }
  622. if (this.timeTrendChart) {
  623. this.timeTrendChart.resize()
  624. }
  625. if (this.categoryEffectChart) {
  626. this.categoryEffectChart.resize()
  627. }
  628. if (this.effectEvaluationChart) {
  629. this.effectEvaluationChart.resize()
  630. }
  631. },
  632. formatUploadDate(date) {
  633. const d = date instanceof Date ? date : new Date(date)
  634. if (Number.isNaN(d.getTime())) return ''
  635. const y = d.getFullYear()
  636. const m = String(d.getMonth() + 1).padStart(2, '0')
  637. const day = String(d.getDate()).padStart(2, '0')
  638. return `${y}-${m}-${day}`
  639. },
  640. exportResults() {
  641. if (!this.hasResults) {
  642. this.$modal.msgError('暂无可导出的分析结果')
  643. return
  644. }
  645. const payload = JSON.stringify(this.results || {}, null, 2)
  646. const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
  647. const url = URL.createObjectURL(blob)
  648. const a = document.createElement('a')
  649. a.href = url
  650. a.download = `sale_effect_results_${this.formatUploadDate(new Date())}.json`
  651. document.body.appendChild(a)
  652. a.click()
  653. document.body.removeChild(a)
  654. URL.revokeObjectURL(url)
  655. }
  656. }
  657. }
  658. </script>
  659. <style scoped lang="scss">
  660. .app-container {
  661. padding: 20px;
  662. }
  663. .page-header {
  664. margin-bottom: 20px;
  665. h2 {
  666. font-size: 24px;
  667. font-weight: 600;
  668. color: #303133;
  669. margin-bottom: 8px;
  670. i {
  671. margin-right: 8px;
  672. color: #409EFF;
  673. }
  674. }
  675. .page-desc {
  676. color: #909399;
  677. font-size: 14px;
  678. margin: 0;
  679. }
  680. }
  681. .mb-20 {
  682. margin-bottom: 20px;
  683. }
  684. .upload-toolbar {
  685. display: flex;
  686. align-items: center;
  687. justify-content: space-between;
  688. background: #ffffff;
  689. border: 1px solid #e6eaf2;
  690. border-radius: 8px;
  691. padding: 12px 16px;
  692. margin-bottom: 16px;
  693. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  694. }
  695. .toolbar-left {
  696. display: flex;
  697. align-items: center;
  698. gap: 12px;
  699. }
  700. .toolbar-upload ::v-deep .el-upload {
  701. display: inline-flex;
  702. }
  703. .toolbar-status {
  704. font-size: 13px;
  705. color: #16a34a;
  706. background: #f0fdf4;
  707. border: 1px solid #dcfce7;
  708. border-radius: 6px;
  709. padding: 6px 10px;
  710. }
  711. .toolbar-status.muted {
  712. color: #6b7280;
  713. background: #f8fafc;
  714. border-color: #e2e8f0;
  715. }
  716. ::v-deep .el-card {
  717. border-radius: 8px;
  718. border: 1px solid #e6eaf2;
  719. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  720. }
  721. ::v-deep .el-card__header {
  722. border-bottom: 1px solid #eef2f7;
  723. }
  724. .stat-card {
  725. .stat-content {
  726. display: flex;
  727. justify-content: space-between;
  728. align-items: flex-start;
  729. .stat-info {
  730. flex: 1;
  731. .stat-label {
  732. font-size: 12px;
  733. color: #909399;
  734. margin: 0 0 8px 0;
  735. text-transform: uppercase;
  736. letter-spacing: 0.5px;
  737. }
  738. .stat-value {
  739. font-size: 28px;
  740. font-weight: bold;
  741. color: #303133;
  742. margin: 0 0 8px 0;
  743. }
  744. .stat-desc {
  745. font-size: 12px;
  746. color: #909399;
  747. margin: 0;
  748. &.stat-desc-success {
  749. color: #67C23A;
  750. }
  751. }
  752. }
  753. .stat-icon {
  754. width: 48px;
  755. height: 48px;
  756. border-radius: 50%;
  757. display: flex;
  758. align-items: center;
  759. justify-content: center;
  760. font-size: 20px;
  761. &.stat-icon-purple {
  762. background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
  763. color: #6366f1;
  764. }
  765. &.stat-icon-blue {
  766. background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
  767. color: #3b82f6;
  768. }
  769. &.stat-icon-teal {
  770. background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
  771. color: #14b8a6;
  772. }
  773. &.stat-icon-green {
  774. background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
  775. color: #10b981;
  776. }
  777. &.stat-icon-yellow {
  778. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  779. color: #f59e0b;
  780. }
  781. &.stat-icon-red {
  782. background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
  783. color: #ef4444;
  784. }
  785. }
  786. }
  787. }
  788. ::v-deep .el-card__header {
  789. display: flex;
  790. justify-content: space-between;
  791. align-items: center;
  792. .header-desc {
  793. font-size: 12px;
  794. color: #909399;
  795. font-weight: normal;
  796. }
  797. }
  798. </style>