index.vue 27 KB

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