|
|
@@ -0,0 +1,819 @@
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <!-- 页面标题 -->
|
|
|
+ <div class="page-header">
|
|
|
+ <h2><i class="el-icon-data-analysis"></i> SKU生命周期整体看板</h2>
|
|
|
+ <p class="page-desc">全局SKU生命周期分析概览,包含关键指标和趋势分析</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 文件上传区域 -->
|
|
|
+ <el-card class="mb-20">
|
|
|
+ <div slot="header">
|
|
|
+ <span><i class="el-icon-upload"></i> 数据文件上传</span>
|
|
|
+ </div>
|
|
|
+ <el-upload
|
|
|
+ ref="upload"
|
|
|
+ :limit="1"
|
|
|
+ accept=".xlsx,.xls,.csv"
|
|
|
+ :http-request="customUpload"
|
|
|
+ :disabled="upload.isUploading"
|
|
|
+ :on-progress="handleFileUploadProgress"
|
|
|
+ :before-upload="beforeUpload"
|
|
|
+ :auto-upload="false"
|
|
|
+ drag
|
|
|
+ >
|
|
|
+ <i class="el-icon-upload"></i>
|
|
|
+ <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
|
|
+ <div class="el-upload__tip" slot="tip">
|
|
|
+ <el-checkbox v-model="upload.updateSupport" /> 是否覆盖已上传的文件
|
|
|
+ <div>只能上传xlsx/xls/csv文件,且不超过20MB</div>
|
|
|
+ </div>
|
|
|
+ </el-upload>
|
|
|
+ <div style="margin-top: 15px">
|
|
|
+ <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">立即上传并分析</el-button>
|
|
|
+ <el-button @click="resetUpload">重置</el-button>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 关键指标卡片 -->
|
|
|
+ <el-row :gutter="20" class="mb-20">
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">订单总数(估)</p>
|
|
|
+ <p class="stat-value">{{ orderCount }}</p>
|
|
|
+ <p class="stat-desc">按销量近似</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-purple">
|
|
|
+ <i class="el-icon-s-order"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">SKU总数</p>
|
|
|
+ <p class="stat-value">{{ skuCount }}</p>
|
|
|
+ <p class="stat-desc stat-desc-success">分析后</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-blue">
|
|
|
+ <i class="el-icon-box"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">商品总数</p>
|
|
|
+ <p class="stat-value">{{ productCount }}</p>
|
|
|
+ <p class="stat-desc">按详情名称去重</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-teal">
|
|
|
+ <i class="el-icon-s-goods"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">完整生命周期SKU</p>
|
|
|
+ <p class="stat-value">{{ completeCount }}</p>
|
|
|
+ <p class="stat-desc stat-desc-success">完整占比 {{ completeRatio }}%</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-green">
|
|
|
+ <i class="el-icon-success"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">平均生命周期时长(天)</p>
|
|
|
+ <p class="stat-value">{{ avgLifecycleDays }}</p>
|
|
|
+ <p class="stat-desc">仅计算完整SKU</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-yellow">
|
|
|
+ <i class="el-icon-time"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="12" :md="8" :lg="4">
|
|
|
+ <el-card class="stat-card">
|
|
|
+ <div class="stat-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <p class="stat-label">数据不足SKU</p>
|
|
|
+ <p class="stat-value">{{ insufficientCount }}</p>
|
|
|
+ <p class="stat-desc">天数/字段不足</p>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-red">
|
|
|
+ <i class="el-icon-warning"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- 阶段分布与关键指标图 -->
|
|
|
+ <el-row :gutter="20" class="mb-20">
|
|
|
+ <el-col :xs="24" :sm="24" :md="12" :lg="12">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span><i class="el-icon-pie-chart"></i> SKU生命周期阶段分布</span>
|
|
|
+ <span class="header-desc">按数量占比</span>
|
|
|
+ </div>
|
|
|
+ <div ref="stageDistributionChart" style="height: 400px"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="24" :md="12" :lg="12">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span><i class="el-icon-data-line"></i> 各阶段关键指标对比</span>
|
|
|
+ <span class="header-desc">销售额/销量(估)</span>
|
|
|
+ </div>
|
|
|
+ <div ref="stageMetricsChart" style="height: 400px"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- 阶段平均时长与转化漏斗 -->
|
|
|
+ <el-row :gutter="20" class="mb-20">
|
|
|
+ <el-col :xs="24" :sm="24" :md="12" :lg="12">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span><i class="el-icon-s-marketing"></i> 阶段平均时长(天)</span>
|
|
|
+ <span class="header-desc">完整SKU统计</span>
|
|
|
+ </div>
|
|
|
+ <div ref="avgDurationChart" style="height: 400px"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :sm="24" :md="12" :lg="12">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span><i class="el-icon-s-data"></i> 阶段转化漏斗</span>
|
|
|
+ <span class="header-desc">引入→成长→成熟→衰退</span>
|
|
|
+ </div>
|
|
|
+ <div ref="funnelChart" style="height: 400px"></div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { uploadAndAnalyze, getLifecycleResults } from '@/api/lifecycle'
|
|
|
+import { getToken } from '@/utils/auth'
|
|
|
+import * as echarts from 'echarts'
|
|
|
+require('echarts/theme/macarons')
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'LifecycleOverview',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ // 图表实例
|
|
|
+ stageDistributionChart: null,
|
|
|
+ stageMetricsChart: null,
|
|
|
+ avgDurationChart: null,
|
|
|
+ funnelChart: null,
|
|
|
+ // 数据
|
|
|
+ results: {},
|
|
|
+ // 计算属性数据
|
|
|
+ orderCount: 0,
|
|
|
+ skuCount: 0,
|
|
|
+ productCount: 0,
|
|
|
+ completeCount: 0,
|
|
|
+ completeRatio: 0,
|
|
|
+ avgLifecycleDays: 0,
|
|
|
+ insufficientCount: 0,
|
|
|
+ // 文件上传相关
|
|
|
+ upload: {
|
|
|
+ // 是否显示弹出层
|
|
|
+ open: false,
|
|
|
+ // 弹出层标题
|
|
|
+ title: '',
|
|
|
+ // 是否禁用上传
|
|
|
+ isUploading: false,
|
|
|
+ // 是否更新已经存在的文件
|
|
|
+ updateSupport: 0,
|
|
|
+ // 设置上传的请求头部
|
|
|
+ headers: { Authorization: 'Bearer ' + getToken() },
|
|
|
+ // 上传的地址
|
|
|
+ url: process.env.VUE_APP_BASE_API + '/statistics/lifecycle/upload'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.initCharts()
|
|
|
+ })
|
|
|
+ // 监听窗口大小变化
|
|
|
+ window.addEventListener('resize', this.handleResize)
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ // 销毁图表实例
|
|
|
+ if (this.stageDistributionChart) {
|
|
|
+ this.stageDistributionChart.dispose()
|
|
|
+ }
|
|
|
+ if (this.stageMetricsChart) {
|
|
|
+ this.stageMetricsChart.dispose()
|
|
|
+ }
|
|
|
+ if (this.avgDurationChart) {
|
|
|
+ this.avgDurationChart.dispose()
|
|
|
+ }
|
|
|
+ if (this.funnelChart) {
|
|
|
+ this.funnelChart.dispose()
|
|
|
+ }
|
|
|
+ window.removeEventListener('resize', this.handleResize)
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ /** 文件上传前的校验 */
|
|
|
+ beforeUpload(file) {
|
|
|
+ const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
|
|
+ file.type === 'application/vnd.ms-excel' ||
|
|
|
+ file.type === 'text/csv' ||
|
|
|
+ file.name.endsWith('.xlsx') ||
|
|
|
+ file.name.endsWith('.xls') ||
|
|
|
+ file.name.endsWith('.csv')
|
|
|
+ const isLt20M = file.size / 1024 / 1024 < 20
|
|
|
+
|
|
|
+ if (!isExcel) {
|
|
|
+ this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (!isLt20M) {
|
|
|
+ this.$modal.msgError('上传文件大小不能超过 20MB!')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ },
|
|
|
+ /** 文件上传中处理 */
|
|
|
+ handleFileUploadProgress(event, file, fileList) {
|
|
|
+ this.upload.isUploading = true
|
|
|
+ },
|
|
|
+ /** 自定义上传方法 */
|
|
|
+ customUpload(options) {
|
|
|
+ const file = options.file
|
|
|
+ this.upload.isUploading = true
|
|
|
+ uploadAndAnalyze(file).then(response => {
|
|
|
+ this.upload.isUploading = false
|
|
|
+ if (response.code === 200) {
|
|
|
+ this.$modal.msgSuccess('文件上传并分析成功')
|
|
|
+ // response.data 就是分析结果,格式为 { sku1: {...}, sku2: {...}, ... }
|
|
|
+ this.results = response.data || {}
|
|
|
+ this.calculateMetrics()
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.renderCharts()
|
|
|
+ })
|
|
|
+ options.onSuccess(response)
|
|
|
+ } else {
|
|
|
+ this.$modal.msgError(response.msg || '分析失败')
|
|
|
+ options.onError(new Error(response.msg || '分析失败'))
|
|
|
+ }
|
|
|
+ // 重置上传组件
|
|
|
+ this.$refs.upload.clearFiles()
|
|
|
+ }).catch(error => {
|
|
|
+ this.upload.isUploading = false
|
|
|
+ const errorMsg = error.response?.data?.msg || error.message || '文件上传失败,请重试'
|
|
|
+ this.$modal.msgError(errorMsg)
|
|
|
+ options.onError(error)
|
|
|
+ })
|
|
|
+ },
|
|
|
+ /** 提交上传文件 */
|
|
|
+ submitUpload() {
|
|
|
+ const fileList = this.$refs.upload.uploadFiles
|
|
|
+ if (!fileList || fileList.length === 0) {
|
|
|
+ this.$modal.msgError('请选择要上传的文件')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.$refs.upload.submit()
|
|
|
+ },
|
|
|
+ /** 重置上传 */
|
|
|
+ resetUpload() {
|
|
|
+ this.$refs.upload.clearFiles()
|
|
|
+ },
|
|
|
+ /** 获取生命周期分析结果 */
|
|
|
+ getList() {
|
|
|
+ getLifecycleResults().then(response => {
|
|
|
+ if (response.code === 200 && response.data) {
|
|
|
+ // response.data 就是分析结果,格式为 { sku1: {...}, sku2: {...}, ... }
|
|
|
+ this.results = response.data || {}
|
|
|
+ this.calculateMetrics()
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.renderCharts()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // 如果没有数据,不显示错误,只是不显示图表
|
|
|
+ this.results = {}
|
|
|
+ })
|
|
|
+ },
|
|
|
+ /** 计算关键指标 */
|
|
|
+ calculateMetrics() {
|
|
|
+ // 仅取真实SKU结果,排除聚合概要 _analysis_summary_
|
|
|
+ const entries = Object.entries(this.results || {})
|
|
|
+ const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
|
|
|
+
|
|
|
+ this.skuCount = resultsArr.length
|
|
|
+
|
|
|
+ // 商品总数(按详情名称去重)
|
|
|
+ const names = new Set()
|
|
|
+ resultsArr.forEach(r => {
|
|
|
+ if (r?.details) names.add(r.details)
|
|
|
+ })
|
|
|
+ this.productCount = names.size
|
|
|
+
|
|
|
+ // 完整生命周期SKU
|
|
|
+ this.completeCount = resultsArr.filter(r => !!r?.is_complete).length
|
|
|
+ this.completeRatio = this.skuCount ? ((this.completeCount / this.skuCount) * 100).toFixed(1) : 0
|
|
|
+
|
|
|
+ // 平均生命周期时长(仅计算完整SKU)
|
|
|
+ const completeList = resultsArr.filter(r => !!r?.is_complete)
|
|
|
+ if (completeList.length > 0) {
|
|
|
+ const total = completeList.reduce((s, r) => s + this.getLifecycleDays(r), 0)
|
|
|
+ this.avgLifecycleDays = Math.round(total / completeList.length)
|
|
|
+ } else {
|
|
|
+ this.avgLifecycleDays = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ // 数据不足SKU
|
|
|
+ this.insufficientCount = resultsArr.filter(r => this.isInsufficient(r)).length
|
|
|
+
|
|
|
+ // 订单总数(按销量近似)
|
|
|
+ this.orderCount = resultsArr.reduce((s, r) => s + this.sumArray(r?.quantity_series || []), 0)
|
|
|
+ },
|
|
|
+ /** 初始化图表 */
|
|
|
+ initCharts() {
|
|
|
+ if (this.$refs.stageDistributionChart) {
|
|
|
+ this.stageDistributionChart = echarts.init(this.$refs.stageDistributionChart, 'macarons')
|
|
|
+ }
|
|
|
+ if (this.$refs.stageMetricsChart) {
|
|
|
+ this.stageMetricsChart = echarts.init(this.$refs.stageMetricsChart, 'macarons')
|
|
|
+ }
|
|
|
+ if (this.$refs.avgDurationChart) {
|
|
|
+ this.avgDurationChart = echarts.init(this.$refs.avgDurationChart, 'macarons')
|
|
|
+ }
|
|
|
+ if (this.$refs.funnelChart) {
|
|
|
+ this.funnelChart = echarts.init(this.$refs.funnelChart, 'macarons')
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 渲染所有图表 */
|
|
|
+ renderCharts() {
|
|
|
+ const entries = Object.entries(this.results || {})
|
|
|
+ const resultsArr = entries.filter(([k]) => k !== '_analysis_summary_').map(([, v]) => v)
|
|
|
+
|
|
|
+ // 1. SKU生命周期阶段分布(饼图)
|
|
|
+ this.renderStageDistribution(resultsArr)
|
|
|
+
|
|
|
+ // 2. 各阶段关键指标对比(柱状图)
|
|
|
+ this.renderStageMetrics(resultsArr)
|
|
|
+
|
|
|
+ // 3. 阶段平均时长(柱状图)
|
|
|
+ this.renderAvgDuration(resultsArr)
|
|
|
+
|
|
|
+ // 4. 阶段转化漏斗(横向条形图)
|
|
|
+ this.renderFunnel(resultsArr)
|
|
|
+ },
|
|
|
+ /** 渲染阶段分布饼图 */
|
|
|
+ renderStageDistribution(list) {
|
|
|
+ const dist = { 引入期: 0, 成长期: 0, 成熟期: 0, 衰退期: 0 }
|
|
|
+ list.forEach(r => {
|
|
|
+ const cur = this.normalizeStage(r?.current_stage)
|
|
|
+ if (cur && dist[cur] != null) dist[cur]++
|
|
|
+ })
|
|
|
+
|
|
|
+ const data = Object.keys(dist).map(key => ({
|
|
|
+ value: dist[key],
|
|
|
+ name: key
|
|
|
+ }))
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: '{a} <br/>{b}: {c} ({d}%)'
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ orient: 'vertical',
|
|
|
+ left: 'left',
|
|
|
+ data: Object.keys(dist)
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '阶段分布',
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['40%', '70%'],
|
|
|
+ avoidLabelOverlap: false,
|
|
|
+ itemStyle: {
|
|
|
+ borderRadius: 10,
|
|
|
+ borderColor: '#fff',
|
|
|
+ borderWidth: 2
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ formatter: '{b}: {c}\n({d}%)'
|
|
|
+ },
|
|
|
+ emphasis: {
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: 'bold'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data: data,
|
|
|
+ color: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.stageDistributionChart) {
|
|
|
+ this.stageDistributionChart.setOption(option)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 渲染各阶段关键指标对比 */
|
|
|
+ renderStageMetrics(list) {
|
|
|
+ const agg = this.aggregateStageMetrics(list)
|
|
|
+ const metricLabels = Object.keys(agg)
|
|
|
+ const revenueAgg = metricLabels.map(k => agg[k].totalRevenue)
|
|
|
+ const qtyAgg = metricLabels.map(k => agg[k].totalQuantity)
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'shadow'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: ['销售额(总计)', '销量(总计)']
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: metricLabels
|
|
|
+ },
|
|
|
+ yAxis: [
|
|
|
+ {
|
|
|
+ type: 'value',
|
|
|
+ name: '销售额',
|
|
|
+ position: 'left'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'value',
|
|
|
+ name: '销量',
|
|
|
+ position: 'right'
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '销售额(总计)',
|
|
|
+ type: 'bar',
|
|
|
+ data: revenueAgg,
|
|
|
+ itemStyle: {
|
|
|
+ color: 'rgba(59,130,246,0.7)'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '销量(总计)',
|
|
|
+ type: 'bar',
|
|
|
+ yAxisIndex: 1,
|
|
|
+ data: qtyAgg,
|
|
|
+ itemStyle: {
|
|
|
+ color: 'rgba(100,116,139,0.7)'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.stageMetricsChart) {
|
|
|
+ this.stageMetricsChart.setOption(option)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 渲染阶段平均时长 */
|
|
|
+ renderAvgDuration(list) {
|
|
|
+ const avgDur = this.averageStageDuration(list)
|
|
|
+ const durLabels = Object.keys(avgDur)
|
|
|
+ const durValues = Object.values(avgDur)
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'shadow'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: durLabels
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '天数'
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '平均持续天数',
|
|
|
+ type: 'bar',
|
|
|
+ data: durValues,
|
|
|
+ itemStyle: {
|
|
|
+ color: 'rgba(245,158,11,0.7)'
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'top'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.avgDurationChart) {
|
|
|
+ this.avgDurationChart.setOption(option)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 渲染阶段转化漏斗 */
|
|
|
+ renderFunnel(list) {
|
|
|
+ const funnelData = this.stageFunnel(list)
|
|
|
+ const funnelLabels = funnelData.labels
|
|
|
+ const funnelValues = funnelData.values
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'shadow'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '转化率(%)'
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: funnelLabels
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '转化率',
|
|
|
+ type: 'bar',
|
|
|
+ data: funnelValues,
|
|
|
+ itemStyle: {
|
|
|
+ color: 'rgba(59,130,246,0.7)'
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'right',
|
|
|
+ formatter: '{c}%'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.funnelChart) {
|
|
|
+ this.funnelChart.setOption(option)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 窗口大小变化处理 */
|
|
|
+ handleResize() {
|
|
|
+ if (this.stageDistributionChart) {
|
|
|
+ this.stageDistributionChart.resize()
|
|
|
+ }
|
|
|
+ if (this.stageMetricsChart) {
|
|
|
+ this.stageMetricsChart.resize()
|
|
|
+ }
|
|
|
+ if (this.avgDurationChart) {
|
|
|
+ this.avgDurationChart.resize()
|
|
|
+ }
|
|
|
+ if (this.funnelChart) {
|
|
|
+ this.funnelChart.resize()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /** 工具函数 */
|
|
|
+ sumArray(arr) {
|
|
|
+ return (arr || []).reduce((s, v) => s + (Number(v) || 0), 0)
|
|
|
+ },
|
|
|
+ getLifecycleDays(r) {
|
|
|
+ const stats = r?.stage_statistics || {}
|
|
|
+ return Object.values(stats).reduce((s, v) => s + (v.durationDays || 0), 0)
|
|
|
+ },
|
|
|
+ isInsufficient(r) {
|
|
|
+ const days = (r?.date_series || []).length
|
|
|
+ const stats = r?.stage_statistics || {}
|
|
|
+ return days < 120 || Object.keys(stats).length === 0
|
|
|
+ },
|
|
|
+ normalizeStage(s) {
|
|
|
+ if (!s) return ''
|
|
|
+ if (s.includes('导入') || s.includes('引入')) return '引入期'
|
|
|
+ if (s.includes('成长')) return '成长期'
|
|
|
+ if (s.includes('成熟')) return '成熟期'
|
|
|
+ if (s.includes('衰退')) return '衰退期'
|
|
|
+ return s
|
|
|
+ },
|
|
|
+ averageStageDuration(list) {
|
|
|
+ // 仅统计完整生命周期SKU
|
|
|
+ const filtered = list.filter(r => !!r?.is_complete)
|
|
|
+ const agg = { 引入期: [], 成长期: [], 成熟期: [], 衰退期: [] }
|
|
|
+ filtered.forEach(r => {
|
|
|
+ const stats = r?.stage_statistics || {}
|
|
|
+ Object.entries(stats).forEach(([stage, v]) => {
|
|
|
+ const key = this.normalizeStage(stage)
|
|
|
+ if (agg[key]) agg[key].push(v?.durationDays || 0)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ const res = {}
|
|
|
+ Object.entries(agg).forEach(([k, arr]) => {
|
|
|
+ res[k] = arr.length ? Math.round(arr.reduce((s, x) => s + (Number(x) || 0), 0) / arr.length) : 0
|
|
|
+ })
|
|
|
+ return res
|
|
|
+ },
|
|
|
+ aggregateStageMetrics(list) {
|
|
|
+ const agg = {
|
|
|
+ 引入期: { totalRevenue: 0, totalQuantity: 0 },
|
|
|
+ 成长期: { totalRevenue: 0, totalQuantity: 0 },
|
|
|
+ 成熟期: { totalRevenue: 0, totalQuantity: 0 },
|
|
|
+ 衰退期: { totalRevenue: 0, totalQuantity: 0 }
|
|
|
+ }
|
|
|
+ list.forEach(r => {
|
|
|
+ const stats = r?.stage_statistics || {}
|
|
|
+ Object.entries(stats).forEach(([stage, v]) => {
|
|
|
+ const key = this.normalizeStage(stage)
|
|
|
+ if (agg[key]) {
|
|
|
+ agg[key].totalRevenue += Number(v?.totalRevenue || 0)
|
|
|
+ agg[key].totalQuantity += Number(v?.totalQuantity || 0)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ return agg
|
|
|
+ },
|
|
|
+ stageFunnel(list) {
|
|
|
+ // 顺序转化:引入→成长→成熟→衰退
|
|
|
+ let intro = 0, growth = 0, maturity = 0, decline = 0
|
|
|
+ list.forEach(r => {
|
|
|
+ const stats = r?.stage_statistics || {}
|
|
|
+ const names = Object.keys(stats).map(this.normalizeStage)
|
|
|
+ const hasIntro = names.includes('引入期')
|
|
|
+ const hasGrowth = names.includes('成长期')
|
|
|
+ const hasMaturity = names.includes('成熟期')
|
|
|
+ const hasDecline = names.includes('衰退期')
|
|
|
+ if (hasIntro) intro++
|
|
|
+ if (hasIntro && hasGrowth) growth++
|
|
|
+ if (hasIntro && hasGrowth && hasMaturity) maturity++
|
|
|
+ if (hasIntro && hasGrowth && hasMaturity && hasDecline) decline++
|
|
|
+ })
|
|
|
+ const labels = ['引入期', '成长期', '成熟期', '衰退期']
|
|
|
+ const counts = [intro, growth, maturity, decline]
|
|
|
+ const values = counts.map((c, i) => {
|
|
|
+ if (i === 0) return intro ? 100 : 0
|
|
|
+ const prev = counts[i - 1]
|
|
|
+ return prev ? Number(((c / prev) * 100).toFixed(1)) : 0
|
|
|
+ })
|
|
|
+ return { labels, values }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.app-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 20px;
|
|
|
+
|
|
|
+ h2 {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ i {
|
|
|
+ margin-right: 8px;
|
|
|
+ color: #409EFF;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .page-desc {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+ margin: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.mb-20 {
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.stat-card {
|
|
|
+ .stat-content {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: flex-start;
|
|
|
+
|
|
|
+ .stat-info {
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ .stat-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin: 0 0 8px 0;
|
|
|
+ text-transform: uppercase;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-value {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #303133;
|
|
|
+ margin: 0 0 8px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin: 0;
|
|
|
+
|
|
|
+ &.stat-desc-success {
|
|
|
+ color: #67C23A;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .stat-icon {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 20px;
|
|
|
+
|
|
|
+ &.stat-icon-purple {
|
|
|
+ background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
|
|
+ color: #6366f1;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stat-icon-blue {
|
|
|
+ background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
|
|
+ color: #3b82f6;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stat-icon-teal {
|
|
|
+ background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
|
|
|
+ color: #14b8a6;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stat-icon-green {
|
|
|
+ background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
|
|
+ color: #10b981;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stat-icon-yellow {
|
|
|
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
|
+ color: #f59e0b;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.stat-icon-red {
|
|
|
+ background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
|
|
+ color: #ef4444;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+::v-deep .el-card__header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .header-desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ font-weight: normal;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|