index.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932
  1. <template>
  2. <div class="app-container">
  3. <!-- 页面标题 -->
  4. <div class="page-header">
  5. <h2><i class="el-icon-s-operation"></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">{{ orderCount }}</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">{{ productCount }}</p>
  53. <p class="stat-desc">按商品名称</p>
  54. </div>
  55. <div class="stat-icon stat-icon-blue">
  56. <i class="el-icon-s-goods"></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">{{ totalRevenue }}</p>
  67. <p class="stat-desc">实际支付金额</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">{{ totalQuantity }}</p>
  81. <p class="stat-desc">购买数量</p>
  82. </div>
  83. <div class="stat-icon stat-icon-green">
  84. <i class="el-icon-s-marketing"></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">{{ totalRefund }}</p>
  95. <p class="stat-desc">已退款金额</p>
  96. </div>
  97. <div class="stat-icon stat-icon-yellow">
  98. <i class="el-icon-warning"></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">{{ refundRate }}%</p>
  109. <p class="stat-desc">退款订单占比</p>
  110. </div>
  111. <div class="stat-icon stat-icon-red">
  112. <i class="el-icon-s-flag"></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-data-line"></i> 销售趋势</span>
  124. <span class="header-desc">按日期</span>
  125. </div>
  126. <div ref="salesTrendChart" 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-pie-chart"></i> 商品销售占比</span>
  133. <span class="header-desc">按销售额</span>
  134. </div>
  135. <div ref="productDistributionChart" style="height: 400px"></div>
  136. </el-card>
  137. </el-col>
  138. </el-row>
  139. <!-- 订单状态与退款分析 -->
  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="orderStatusChart" 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-finance"></i> 退款分析</span>
  154. <span class="header-desc">按商品</span>
  155. </div>
  156. <div ref="refundAnalysisChart" style="height: 400px"></div>
  157. </el-card>
  158. </el-col>
  159. </el-row>
  160. </div>
  161. </template>
  162. <script>
  163. import { analyzeFile, getResults } 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: 'SaleControl',
  169. data() {
  170. return {
  171. // 图表实例
  172. salesTrendChart: null,
  173. productDistributionChart: null,
  174. orderStatusChart: null,
  175. refundAnalysisChart: null,
  176. // 数据
  177. results: {},
  178. // 计算属性数据
  179. orderCount: 0,
  180. productCount: 0,
  181. totalRevenue: 0,
  182. totalQuantity: 0,
  183. totalRefund: 0,
  184. refundRate: 0,
  185. // 文件上传相关
  186. upload: {
  187. // 是否显示弹出层
  188. open: false,
  189. // 弹出层标题
  190. title: '',
  191. // 是否禁用上传
  192. isUploading: false,
  193. // 是否更新已经存在的文件
  194. updateSupport: 0,
  195. // 设置上传的请求头部
  196. headers: { Authorization: 'Bearer ' + getToken() },
  197. // 上传的地址
  198. url: process.env.VUE_APP_PYTHON_API + '/api/sale-control/upload',
  199. // 文件名称
  200. fileName: '',
  201. // 已选择文件名称
  202. pendingFileName: '',
  203. // 是否忽略文件选择改变
  204. ignoreFileChange: false,
  205. }
  206. }
  207. },
  208. computed: {
  209. hasResults() {
  210. return Object.keys(this.results || {}).length > 0
  211. }
  212. },
  213. mounted() {
  214. this.$nextTick(() => {
  215. this.initCharts()
  216. })
  217. // 监听窗口大小变化
  218. window.addEventListener('resize', this.handleResize)
  219. },
  220. beforeDestroy() {
  221. // 销毁图表实例
  222. if (this.salesTrendChart) {
  223. this.salesTrendChart.dispose()
  224. }
  225. if (this.productDistributionChart) {
  226. this.productDistributionChart.dispose()
  227. }
  228. if (this.orderStatusChart) {
  229. this.orderStatusChart.dispose()
  230. }
  231. if (this.refundAnalysisChart) {
  232. this.refundAnalysisChart.dispose()
  233. }
  234. window.removeEventListener('resize', this.handleResize)
  235. },
  236. methods: {
  237. /** 文件选择改变处理 */
  238. handleFileChange(file, fileList) {
  239. if (this.upload.ignoreChange) return
  240. if (!fileList || fileList.length === 0) return
  241. if (!file || !file.raw) return
  242. this.upload.pendingFileName = file.name
  243. this.upload.fileName = ''
  244. },
  245. /** 文件上传前的校验 */
  246. beforeUpload(file) {
  247. const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
  248. file.type === 'application/vnd.ms-excel' ||
  249. file.type === 'text/csv' ||
  250. file.name.endsWith('.xlsx') ||
  251. file.name.endsWith('.xls') ||
  252. file.name.endsWith('.csv')
  253. const isLt500M = file.size / 1024 / 1024 < 500
  254. if (!isExcel) {
  255. this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
  256. return false
  257. }
  258. if (!isLt500M) {
  259. this.$modal.msgError('上传文件大小不能超过 500MB!')
  260. return false
  261. }
  262. return true
  263. },
  264. /** 文件上传中处理 */
  265. handleFileUploadProgress(event, file, fileList) {
  266. this.upload.isUploading = true
  267. },
  268. /** 自定义上传方法 */
  269. customUpload(options) {
  270. const file = options.file
  271. this.upload.isUploading = true
  272. analyzeFile(file).then(response => {
  273. this.upload.isUploading = false
  274. if (response && response.success) {
  275. this.$modal.msgSuccess('文件上传并分析成功')
  276. this.upload.fileName = this.upload.pendingFileName || file.name
  277. this.upload.pendingFileName = ''
  278. this.results = response.data || {}
  279. this.calculateMetrics()
  280. this.$nextTick(() => {
  281. this.renderCharts()
  282. })
  283. options.onSuccess(response)
  284. } else {
  285. const message = (response && response.message) || '分析失败'
  286. this.$modal.msgError(message)
  287. options.onError(new Error(message))
  288. }
  289. if (this.$refs.upload) {
  290. this.upload.ignoreFileChange = true
  291. this.$refs.upload.clearFiles()
  292. this.$nextTick(() => {
  293. this.upload.ignoreFileChange = false
  294. })
  295. }
  296. }).catch(error => {
  297. this.upload.isUploading = false
  298. const errorMsg = (error && error.response && error.response.data && error.response.data.message) || error.message || '文件上传失败,请重试'
  299. this.$modal.msgError(errorMsg)
  300. options.onError(error)
  301. })
  302. },
  303. /** 提交上传文件 */
  304. submitUpload() {
  305. const fileList = this.$refs.upload.uploadFiles
  306. if (!fileList || fileList.length === 0) {
  307. this.$modal.msgError('请选择要上传的文件')
  308. return
  309. }
  310. this.$refs.upload.submit()
  311. },
  312. /** 重置上传 */
  313. resetUpload() {
  314. if (this.$refs.upload) this.$refs.upload.clearFiles()
  315. if (this.$refs.toolbarUpload) this.$refs.toolbarUpload.clearFiles()
  316. },
  317. /** 获取销售分析结果 */
  318. getList() {
  319. getResults().then(response => {
  320. if (response && response.success && response.data) {
  321. this.results = response.data || {}
  322. this.calculateMetrics()
  323. this.$nextTick(() => {
  324. this.renderCharts()
  325. })
  326. }
  327. }).catch(() => {
  328. this.results = {}
  329. })
  330. },
  331. /** 计算关键指标 */
  332. calculateMetrics() {
  333. // 仅取真实销售数据,排除聚合概要
  334. const entries = Object.entries(this.results || {})
  335. const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
  336. this.orderCount = resultsArr.length
  337. // 商品总数(按商品名称去重)
  338. const products = new Set()
  339. resultsArr.forEach(r => {
  340. if (r?.商品名称) products.add(r.商品名称)
  341. })
  342. this.productCount = products.size
  343. // 总销售额
  344. this.totalRevenue = resultsArr.reduce((s, r) => s + Number(r?.买家实际支付金额 || 0), 0)
  345. // 总销量
  346. this.totalQuantity = resultsArr.reduce((s, r) => s + Number(r?.购买数量 || 0), 0)
  347. // 总退款金额
  348. this.totalRefund = resultsArr.reduce((s, r) => {
  349. if (r?.退款状态 && r?.退款状态 !== '没有申请退款' && r?.退款金额) {
  350. return s + Number(r.退款金额)
  351. }
  352. return s
  353. }, 0)
  354. // 退款率
  355. const refundCount = resultsArr.filter(r => r?.退款状态 && r?.退款状态 !== '没有申请退款').length
  356. this.refundRate = this.orderCount ? ((refundCount / this.orderCount) * 100).toFixed(1) : 0
  357. },
  358. /** 初始化图表 */
  359. initCharts() {
  360. if (this.$refs.salesTrendChart) {
  361. this.salesTrendChart = echarts.init(this.$refs.salesTrendChart, 'macarons')
  362. }
  363. if (this.$refs.productDistributionChart) {
  364. this.productDistributionChart = echarts.init(this.$refs.productDistributionChart, 'macarons')
  365. }
  366. if (this.$refs.orderStatusChart) {
  367. this.orderStatusChart = echarts.init(this.$refs.orderStatusChart, 'macarons')
  368. }
  369. if (this.$refs.refundAnalysisChart) {
  370. this.refundAnalysisChart = echarts.init(this.$refs.refundAnalysisChart, 'macarons')
  371. }
  372. },
  373. /** 渲染所有图表 */
  374. renderCharts() {
  375. const entries = Object.entries(this.results || {})
  376. const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
  377. // 1. 销售趋势(折线图)
  378. this.renderSalesTrend(resultsArr)
  379. // 2. 商品销售占比(饼图)
  380. this.renderProductDistribution(resultsArr)
  381. // 3. 订单状态分布(柱状图)
  382. this.renderOrderStatus(resultsArr)
  383. // 4. 退款分析(柱状图)
  384. this.renderRefundAnalysis(resultsArr)
  385. },
  386. /** 渲染销售趋势图 */
  387. renderSalesTrend(list) {
  388. // 按日期分组
  389. const dateMap = {}
  390. list.forEach(item => {
  391. const date = item?.订单创建时间 ? item.订单创建时间.split(' ')[0] : '未知'
  392. if (!dateMap[date]) {
  393. dateMap[date] = {
  394. revenue: 0,
  395. quantity: 0
  396. }
  397. }
  398. dateMap[date].revenue += Number(item?.买家实际支付金额 || 0)
  399. dateMap[date].quantity += Number(item?.购买数量 || 0)
  400. })
  401. // 按日期排序
  402. const sortedDates = Object.keys(dateMap).sort()
  403. const dates = sortedDates
  404. const revenues = sortedDates.map(date => dateMap[date].revenue)
  405. const quantities = sortedDates.map(date => dateMap[date].quantity)
  406. const option = {
  407. tooltip: {
  408. trigger: 'axis',
  409. axisPointer: {
  410. type: 'cross',
  411. label: {
  412. backgroundColor: '#6a7985'
  413. }
  414. }
  415. },
  416. legend: {
  417. data: ['销售额', '销量']
  418. },
  419. grid: {
  420. left: '3%',
  421. right: '4%',
  422. bottom: '3%',
  423. containLabel: true
  424. },
  425. xAxis: [
  426. {
  427. type: 'category',
  428. boundaryGap: false,
  429. data: dates
  430. }
  431. ],
  432. yAxis: [
  433. {
  434. type: 'value',
  435. name: '销售额',
  436. position: 'left'
  437. },
  438. {
  439. type: 'value',
  440. name: '销量',
  441. position: 'right'
  442. }
  443. ],
  444. series: [
  445. {
  446. name: '销售额',
  447. type: 'line',
  448. stack: 'Total',
  449. areaStyle: {},
  450. emphasis: {
  451. focus: 'series'
  452. },
  453. data: revenues,
  454. itemStyle: {
  455. color: '#3b82f6'
  456. }
  457. },
  458. {
  459. name: '销量',
  460. type: 'line',
  461. stack: 'Total',
  462. emphasis: {
  463. focus: 'series'
  464. },
  465. data: quantities,
  466. yAxisIndex: 1,
  467. itemStyle: {
  468. color: '#10b981'
  469. }
  470. }
  471. ]
  472. }
  473. if (this.salesTrendChart) {
  474. this.salesTrendChart.setOption(option)
  475. }
  476. },
  477. /** 渲染商品销售占比饼图 */
  478. renderProductDistribution(list) {
  479. // 按商品名称分组
  480. const productMap = {}
  481. list.forEach(item => {
  482. const product = item?.商品名称 || '未知'
  483. if (!productMap[product]) {
  484. productMap[product] = 0
  485. }
  486. productMap[product] += Number(item?.买家实际支付金额 || 0)
  487. })
  488. // 转换为饼图数据
  489. const data = Object.entries(productMap).map(([name, value]) => ({
  490. name,
  491. value
  492. }))
  493. const option = {
  494. tooltip: {
  495. trigger: 'item',
  496. formatter: '{a} <br/>{b}: {c} ({d}%)'
  497. },
  498. legend: {
  499. orient: 'vertical',
  500. left: 'left',
  501. data: Object.keys(productMap)
  502. },
  503. series: [
  504. {
  505. name: '商品销售',
  506. type: 'pie',
  507. radius: ['40%', '70%'],
  508. avoidLabelOverlap: false,
  509. itemStyle: {
  510. borderRadius: 10,
  511. borderColor: '#fff',
  512. borderWidth: 2
  513. },
  514. label: {
  515. show: true,
  516. formatter: '{b}: {c}\n({d}%)'
  517. },
  518. emphasis: {
  519. label: {
  520. show: true,
  521. fontSize: 16,
  522. fontWeight: 'bold'
  523. }
  524. },
  525. data: data
  526. }
  527. ]
  528. }
  529. if (this.productDistributionChart) {
  530. this.productDistributionChart.setOption(option)
  531. }
  532. },
  533. /** 渲染订单状态分布 */
  534. renderOrderStatus(list) {
  535. // 按订单状态分组
  536. const statusMap = {}
  537. list.forEach(item => {
  538. const status = item?.订单状态 || '未知'
  539. if (!statusMap[status]) {
  540. statusMap[status] = 0
  541. }
  542. statusMap[status]++
  543. })
  544. const statuses = Object.keys(statusMap)
  545. const counts = Object.values(statusMap)
  546. const option = {
  547. tooltip: {
  548. trigger: 'axis',
  549. axisPointer: {
  550. type: 'shadow'
  551. }
  552. },
  553. grid: {
  554. left: '3%',
  555. right: '4%',
  556. bottom: '3%',
  557. containLabel: true
  558. },
  559. xAxis: {
  560. type: 'category',
  561. data: statuses
  562. },
  563. yAxis: {
  564. type: 'value',
  565. name: '订单数量'
  566. },
  567. series: [
  568. {
  569. name: '订单数量',
  570. type: 'bar',
  571. data: counts,
  572. itemStyle: {
  573. color: 'rgba(59,130,246,0.7)'
  574. },
  575. label: {
  576. show: true,
  577. position: 'top'
  578. }
  579. }
  580. ]
  581. }
  582. if (this.orderStatusChart) {
  583. this.orderStatusChart.setOption(option)
  584. }
  585. },
  586. /** 渲染退款分析 */
  587. renderRefundAnalysis(list) {
  588. // 按商品名称分组,计算退款金额
  589. const refundMap = {}
  590. list.forEach(item => {
  591. if (item?.退款状态 && item?.退款状态 !== '没有申请退款' && item?.退款金额) {
  592. const product = item?.商品名称 || '未知'
  593. if (!refundMap[product]) {
  594. refundMap[product] = 0
  595. }
  596. refundMap[product] += Number(item?.退款金额 || 0)
  597. }
  598. })
  599. const products = Object.keys(refundMap)
  600. const refunds = Object.values(refundMap)
  601. const option = {
  602. tooltip: {
  603. trigger: 'axis',
  604. axisPointer: {
  605. type: 'shadow'
  606. }
  607. },
  608. grid: {
  609. left: '3%',
  610. right: '4%',
  611. bottom: '3%',
  612. containLabel: true
  613. },
  614. xAxis: {
  615. type: 'category',
  616. data: products
  617. },
  618. yAxis: {
  619. type: 'value',
  620. name: '退款金额'
  621. },
  622. series: [
  623. {
  624. name: '退款金额',
  625. type: 'bar',
  626. data: refunds,
  627. itemStyle: {
  628. color: 'rgba(239,68,68,0.7)'
  629. },
  630. label: {
  631. show: true,
  632. position: 'top'
  633. }
  634. }
  635. ]
  636. }
  637. if (this.refundAnalysisChart) {
  638. this.refundAnalysisChart.setOption(option)
  639. }
  640. },
  641. /** 窗口大小变化处理 */
  642. handleResize() {
  643. if (this.salesTrendChart) {
  644. this.salesTrendChart.resize()
  645. }
  646. if (this.productDistributionChart) {
  647. this.productDistributionChart.resize()
  648. }
  649. if (this.orderStatusChart) {
  650. this.orderStatusChart.resize()
  651. }
  652. if (this.refundAnalysisChart) {
  653. this.refundAnalysisChart.resize()
  654. }
  655. },
  656. /** 工具函数 */
  657. formatUploadDate(date) {
  658. const d = date instanceof Date ? date : new Date(date)
  659. if (Number.isNaN(d.getTime())) return ''
  660. const y = d.getFullYear()
  661. const m = String(d.getMonth() + 1).padStart(2, '0')
  662. const day = String(d.getDate()).padStart(2, '0')
  663. return `${y}-${m}-${day}`
  664. },
  665. exportResults() {
  666. if (!this.hasResults) {
  667. this.$modal.msgError('暂无可导出的分析结果')
  668. return
  669. }
  670. const payload = JSON.stringify(this.results || {}, null, 2)
  671. const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
  672. const url = URL.createObjectURL(blob)
  673. const a = document.createElement('a')
  674. a.href = url
  675. a.download = `sale_control_results_${this.formatUploadDate(new Date())}.json`
  676. document.body.appendChild(a)
  677. a.click()
  678. document.body.removeChild(a)
  679. URL.revokeObjectURL(url)
  680. }
  681. }
  682. }
  683. </script>
  684. <style scoped lang="scss">
  685. .app-container {
  686. padding: 20px;
  687. }
  688. .page-header {
  689. margin-bottom: 20px;
  690. h2 {
  691. font-size: 24px;
  692. font-weight: 600;
  693. color: #303133;
  694. margin-bottom: 8px;
  695. i {
  696. margin-right: 8px;
  697. color: #409EFF;
  698. }
  699. }
  700. .page-desc {
  701. color: #909399;
  702. font-size: 14px;
  703. margin: 0;
  704. }
  705. }
  706. .mb-20 {
  707. margin-bottom: 20px;
  708. }
  709. .upload-toolbar {
  710. display: flex;
  711. align-items: center;
  712. justify-content: space-between;
  713. background: #ffffff;
  714. border: 1px solid #e6eaf2;
  715. border-radius: 8px;
  716. padding: 12px 16px;
  717. margin-bottom: 16px;
  718. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  719. }
  720. .toolbar-left {
  721. display: flex;
  722. align-items: center;
  723. gap: 12px;
  724. }
  725. .toolbar-upload ::v-deep .el-upload {
  726. display: inline-flex;
  727. }
  728. .toolbar-status {
  729. font-size: 13px;
  730. color: #16a34a;
  731. background: #f0fdf4;
  732. border: 1px solid #dcfce7;
  733. border-radius: 6px;
  734. padding: 6px 10px;
  735. }
  736. .toolbar-status.muted {
  737. color: #6b7280;
  738. background: #f8fafc;
  739. border-color: #e2e8f0;
  740. }
  741. .upload-card {
  742. border-radius: 8px;
  743. border: 1px solid #e6eaf2;
  744. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  745. }
  746. .upload-card .el-card__header {
  747. padding: 12px 16px;
  748. border-bottom: 1px solid #eef2f7;
  749. }
  750. .upload-row {
  751. display: flex;
  752. align-items: center;
  753. gap: 16px;
  754. }
  755. .upload-compact {
  756. flex: 1;
  757. max-width: 260px;
  758. }
  759. .upload-compact ::v-deep .el-upload-dragger {
  760. width: 100%;
  761. height: 140px;
  762. padding: 10px 12px;
  763. border-radius: 8px;
  764. }
  765. .upload-compact ::v-deep .el-icon-upload {
  766. font-size: 26px;
  767. margin-bottom: 4px;
  768. }
  769. .upload-compact ::v-deep .el-upload__text {
  770. font-size: 13px;
  771. }
  772. .upload-compact ::v-deep .el-upload__tip {
  773. margin-top: 6px;
  774. font-size: 12px;
  775. color: #909399;
  776. }
  777. .upload-actions {
  778. display: flex;
  779. flex-direction: column;
  780. gap: 10px;
  781. min-width: 120px;
  782. }
  783. ::v-deep .el-card {
  784. border-radius: 8px;
  785. border: 1px solid #e6eaf2;
  786. box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
  787. }
  788. ::v-deep .el-card__header {
  789. border-bottom: 1px solid #eef2f7;
  790. }
  791. .stat-card {
  792. .stat-content {
  793. display: flex;
  794. justify-content: space-between;
  795. align-items: flex-start;
  796. .stat-info {
  797. flex: 1;
  798. .stat-label {
  799. font-size: 12px;
  800. color: #909399;
  801. margin: 0 0 8px 0;
  802. text-transform: uppercase;
  803. letter-spacing: 0.5px;
  804. }
  805. .stat-value {
  806. font-size: 28px;
  807. font-weight: bold;
  808. color: #303133;
  809. margin: 0 0 8px 0;
  810. }
  811. .stat-desc {
  812. font-size: 12px;
  813. color: #909399;
  814. margin: 0;
  815. &.stat-desc-success {
  816. color: #67C23A;
  817. }
  818. }
  819. }
  820. .stat-icon {
  821. width: 48px;
  822. height: 48px;
  823. border-radius: 50%;
  824. display: flex;
  825. align-items: center;
  826. justify-content: center;
  827. font-size: 20px;
  828. &.stat-icon-purple {
  829. background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
  830. color: #6366f1;
  831. }
  832. &.stat-icon-blue {
  833. background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
  834. color: #3b82f6;
  835. }
  836. &.stat-icon-teal {
  837. background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
  838. color: #14b8a6;
  839. }
  840. &.stat-icon-green {
  841. background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
  842. color: #10b981;
  843. }
  844. &.stat-icon-yellow {
  845. background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
  846. color: #f59e0b;
  847. }
  848. &.stat-icon-red {
  849. background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
  850. color: #ef4444;
  851. }
  852. }
  853. }
  854. }
  855. ::v-deep .el-card__header {
  856. display: flex;
  857. justify-content: space-between;
  858. align-items: center;
  859. .header-desc {
  860. font-size: 12px;
  861. color: #909399;
  862. font-weight: normal;
  863. }
  864. }
  865. </style>