|
@@ -1,1120 +0,0 @@
|
|
|
-<template>
|
|
|
|
|
- <div class="app-container">
|
|
|
|
|
- <!-- 页面标题 -->
|
|
|
|
|
- <div class="page-header">
|
|
|
|
|
- <h2><i class="el-icon-data-analysis"></i> SPU生命周期详细分析</h2>
|
|
|
|
|
- <p class="page-desc">单个SPU的完整生命周期分析,包含趋势、指标和阶段详情</p>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div class="upload-toolbar">
|
|
|
|
|
- <div class="toolbar-left">
|
|
|
|
|
- <el-upload
|
|
|
|
|
- ref="toolbarUpload"
|
|
|
|
|
- class="toolbar-upload"
|
|
|
|
|
- :limit="1"
|
|
|
|
|
- accept=".xlsx,.xls,.csv"
|
|
|
|
|
- :http-request="customUpload"
|
|
|
|
|
- :disabled="upload.isUploading"
|
|
|
|
|
- :on-change="handleFileChange"
|
|
|
|
|
- :before-upload="beforeUpload"
|
|
|
|
|
- :auto-upload="false"
|
|
|
|
|
- :show-file-list="false"
|
|
|
|
|
- >
|
|
|
|
|
- <el-button plain>上传文件</el-button>
|
|
|
|
|
- </el-upload>
|
|
|
|
|
- <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
|
|
|
|
|
- <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
|
|
|
|
|
- <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
|
|
|
|
|
- <div class="toolbar-status muted" v-else>未上传</div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- SPU选择 + 基础信息板块 -->
|
|
|
|
|
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
|
|
- <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
|
|
|
|
- <div class="flex items-center gap-3">
|
|
|
|
|
- <label class="text-sm font-medium text-gray-700">选择SPU:</label>
|
|
|
|
|
- <select class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm spu-select"
|
|
|
|
|
- v-model="selectedSpu">
|
|
|
|
|
- <option v-for="k in spuOptions" :key="k" :value="k">{{ k }}</option>
|
|
|
|
|
- </select>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div class="flex items-center gap-2">
|
|
|
|
|
- <span class="text-sm font-medium text-gray-700">当前阶段:</span>
|
|
|
|
|
- <span class="px-3 py-1 bg-gradient-to-r from-yellow-100 to-amber-100 text-yellow-800 rounded-full text-sm font-medium flex items-center gap-2">
|
|
|
|
|
- {{ currentStage }}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">SPU名称</p>
|
|
|
|
|
- <p class="text-lg font-medium text-gray-800 truncate">{{ selectedSpu }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">生命周期完整性</p>
|
|
|
|
|
- <p class="text-lg font-medium" :class="detail && detail.is_complete ? 'text-green-700' : 'text-orange-600'">
|
|
|
|
|
- {{ detail && detail.is_complete ? '完整' : '不完整' }}
|
|
|
|
|
- <span v-if="detail && detail.completeness_score != null" class="ml-2 text-sm text-gray-500">
|
|
|
|
|
- (得分: {{ detail.completeness_score }}/100)
|
|
|
|
|
- </span>
|
|
|
|
|
- </p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">总销售额</p>
|
|
|
|
|
- <p class="text-lg font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">总销量</p>
|
|
|
|
|
- <p class="text-lg font-medium text-gray-800">{{ totalQty }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 生命周期趋势图(含阶段分界可视化) -->
|
|
|
|
|
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
|
|
- <div class="flex justify-between items-center mb-6">
|
|
|
|
|
- <h3 class="text-lg font-semibold text-gray-800">SPU生命周期趋势图</h3>
|
|
|
|
|
- <div class="flex items-center gap-3">
|
|
|
|
|
- <div class="flex items-center gap-2">
|
|
|
|
|
- <span class="w-3 h-3 rounded-full bg-blue-500"></span>
|
|
|
|
|
- <span class="text-sm text-gray-600">销售额</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="flex items-center gap-2">
|
|
|
|
|
- <span class="w-3 h-3 rounded-full bg-gray-500"></span>
|
|
|
|
|
- <span class="text-sm text-gray-600">销量</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="h-96">
|
|
|
|
|
- <canvas ref="lifeCycleTrendRef"></canvas>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="mt-4 space-y-2">
|
|
|
|
|
- <div v-if="detail && detail.is_complete" class="text-sm">
|
|
|
|
|
- <span :class="hasFourStages ? 'text-green-700 font-medium' : 'text-red-600 font-medium'">
|
|
|
|
|
- <i :class="hasFourStages ? 'fa fa-check-circle' : 'fa fa-exclamation-triangle'" class="mr-1"></i>
|
|
|
|
|
- {{ hasFourStages ? '阶段划分完整:已包含引入/成长/成熟/衰退四个阶段' : '阶段划分不完整:未包含所有四个标准阶段' }}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-if="detail && !detail.is_complete && detail.completeness_score != null" class="text-sm text-orange-600">
|
|
|
|
|
- <i class="fa fa-info-circle mr-1"></i>
|
|
|
|
|
- 不完整生命周期SPU - 完整性评估得分:{{ detail.completeness_score }}/100 (需要聓80分以上才能被评为完整周期)
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 基本指标与阶段分析分组 -->
|
|
|
|
|
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-20">
|
|
|
|
|
- <!-- 基本指标板块 -->
|
|
|
|
|
- <div class="bg-white rounded-xl p-6 shadow-sm space-y-6">
|
|
|
|
|
- <h3 class="text-lg font-semibold text-gray-800">基本指标</h3>
|
|
|
|
|
- <div>
|
|
|
|
|
- <div class="flex justify-between mb-2">
|
|
|
|
|
- <span class="text-sm text-gray-500">总销售额</span>
|
|
|
|
|
- <span class="text-sm font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="progress-bar"><div class="progress-value bg-blue-500" :style="{ width: revenuePct+'%' }"></div></div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div>
|
|
|
|
|
- <div class="flex justify-between mb-2">
|
|
|
|
|
- <span class="text-sm text-gray-500">总销量</span>
|
|
|
|
|
- <span class="text-sm font-medium text-gray-800">{{ totalQty }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="progress-bar"><div class="progress-value bg-gray-500" :style="{ width: qtyPct+'%' }"></div></div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="grid grid-cols-2 gap-4 pt-2">
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销售额</p>
|
|
|
|
|
- <p class="text-lg font-medium text-gray-800">{{ formatCurrency(detail && detail.peak_revenue) }}</p>
|
|
|
|
|
- <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_revenue_date) }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销量</p>
|
|
|
|
|
- <p class="text-lg font-medium text-gray-800">{{ detail && detail.peak_quantity != null ? detail.peak_quantity : '-' }}</p>
|
|
|
|
|
- <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_quantity_date) }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
|
|
- <p class="text-xs text-gray-500 uppercase tracking-wide">当前阶段说明</p>
|
|
|
|
|
- <p class="text-sm text-gray-700">
|
|
|
|
|
- 处于 <span class="font-medium">{{ currentStage }}</span>,已持续
|
|
|
|
|
- <span class="font-medium">{{ currentStageDurationDays }}</span> 天。
|
|
|
|
|
- </p>
|
|
|
|
|
- <p class="text-xs text-gray-600 mt-1">{{ detail && detail.details ? detail.details : '-' }}</p>
|
|
|
|
|
- <p v-if="detail && !detail.is_complete && detail.next_stage_prediction" class="text-xs text-blue-600 mt-1">下一阶段预测:{{ detail.next_stage_prediction }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 阶段分析板块 -->
|
|
|
|
|
- <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
|
|
|
|
|
- <h3 class="text-lg font-semibold text-gray-800 mb-6">阶段分析</h3>
|
|
|
|
|
- <div class="overflow-x-auto">
|
|
|
|
|
- <table class="min-w-full divide-y divide-gray-200">
|
|
|
|
|
- <thead>
|
|
|
|
|
- <tr>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">阶段</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销售额</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销售额占比</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销量</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销量占比</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销售额</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销量</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">持续天数</th>
|
|
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">起止日期</th>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </thead>
|
|
|
|
|
- <tbody class="bg-white divide-y divide-gray-200">
|
|
|
|
|
- <tr v-for="(stats, stage) in displayStageStats" :key="stage">
|
|
|
|
|
- <td class="px-4 py-3 text-sm"><span class="lifecycle-stage" :class="stageClass(stage)">{{ stage }}</span></td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ formatCurrency(stats.totalRevenue) }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.revenuePercentage) }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ stats.totalQuantity != null ? stats.totalQuantity : '-' }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.quantityPercentage) }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm text-gray-700">{{ formatCurrency(stats.avgDailyRevenue) }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm text-gray-700">{{ stats.avgDailyQuantity != null ? stats.avgDailyQuantity : '-' }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-sm text-gray-700">{{ stats.durationDays }}</td>
|
|
|
|
|
- <td class="px-4 py-3 text-xs text-gray-600">{{ formatDate(stats.startDate) }} ~ {{ formatDate(stats.endDate) }}</td>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 对比条形图:各阶段 销售额/销量/持续天数 -->
|
|
|
|
|
- <div class="mt-8">
|
|
|
|
|
- <div class="flex justify-between items-center mb-4">
|
|
|
|
|
- <h4 class="text-base font-semibold text-gray-800">阶段对比条形图</h4>
|
|
|
|
|
- <div class="text-xs text-gray-500">销售额/销量/持续天数</div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="h-64">
|
|
|
|
|
- <canvas ref="stageCompareRef"></canvas>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 完整性评估细则与权重 -->
|
|
|
|
|
- <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
|
|
- <div class="flex justify-between items-center mb-6">
|
|
|
|
|
- <h3 class="text-lg font-semibold text-gray-800">完整性评估细项</h3>
|
|
|
|
|
- <div class="text-sm" :class="detail && detail.is_complete ? 'text-green-600' : 'text-orange-600'">
|
|
|
|
|
- 总分 {{ detail && detail.completeness_score != null ? detail.completeness_score : 0 }}/100
|
|
|
|
|
- <span class="ml-2">{{ detail && detail.is_complete ? '(已达完整标准80分)' : '(未达完整标准)' }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
|
|
- <div v-for="item in breakdownItems" :key="item.key" class="flex items-center justify-between border border-gray-200 rounded-lg px-4 py-3 bg-gray-50">
|
|
|
|
|
- <div class="flex items-center gap-3">
|
|
|
|
|
- <span :class="item.hit ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-3 py-1 rounded-full text-xs font-medium">
|
|
|
|
|
- {{ item.hit ? '命中' : '未命中' }}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span class="text-sm text-gray-800">{{ item.label }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <span class="text-xs text-gray-500 font-medium">权重 {{ item.weight }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-</template>
|
|
|
|
|
-
|
|
|
|
|
-<script setup>
|
|
|
|
|
-import { uploadAndAnalyze, getLifecycleResults } from '@/api/lifecycle'
|
|
|
|
|
-import { analyzeSpuFile, getSpuResults } from '@/api/client'
|
|
|
|
|
-import { getToken } from '@/utils/auth'
|
|
|
|
|
-import { Chart } from 'chart.js'
|
|
|
|
|
-import { formatCurrency, formatDate } from '../../../utils/format'
|
|
|
|
|
-
|
|
|
|
|
-const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
|
|
|
|
|
-
|
|
|
|
|
-export default {
|
|
|
|
|
- name: 'LifecycleSpuDetail',
|
|
|
|
|
- data() {
|
|
|
|
|
- return {
|
|
|
|
|
- trendChart: null,
|
|
|
|
|
- stageCompareChart: null,
|
|
|
|
|
- upload: {
|
|
|
|
|
- // 是否显示弹出层
|
|
|
|
|
- open: false,
|
|
|
|
|
- // 弹出层标题
|
|
|
|
|
- title: '',
|
|
|
|
|
- // 是否禁用上传
|
|
|
|
|
- isUploading: false,
|
|
|
|
|
- // 是否更新已经存在的文件
|
|
|
|
|
- updateSupport: 0,
|
|
|
|
|
- // 设置上传的请求头部
|
|
|
|
|
- headers: { Authorization: 'Bearer ' + getToken() },
|
|
|
|
|
- // 上传的地址
|
|
|
|
|
- url: process.env.VUE_APP_PYTHON_API + '/api/spu-lifecycle/upload',
|
|
|
|
|
- // 文件名称
|
|
|
|
|
- fileName: '',
|
|
|
|
|
- // 已选择文件名称
|
|
|
|
|
- pendingFileName: '',
|
|
|
|
|
- // 是否忽略文件选择改变
|
|
|
|
|
- ignoreFileChange: false,
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- computed: {
|
|
|
|
|
- hasResults() {
|
|
|
|
|
- return Object.keys(this.results || {}).length > 0
|
|
|
|
|
- },
|
|
|
|
|
- results() {
|
|
|
|
|
- const state = this.$store && this.$store.state && this.$store.state.spuAnalysis
|
|
|
|
|
- return (state && state.results) || {}
|
|
|
|
|
- },
|
|
|
|
|
- selectedSpu: {
|
|
|
|
|
- get() {
|
|
|
|
|
- const state = this.$store && this.$store.state && this.$store.state.spuAnalysis
|
|
|
|
|
- return (state && state.selectedSpu) || ''
|
|
|
|
|
- },
|
|
|
|
|
- set(value) {
|
|
|
|
|
- if (this.$store) {
|
|
|
|
|
- this.$store.dispatch('spuAnalysis/selectSpu', value)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- spuOptions() {
|
|
|
|
|
- return Object.keys(this.results || {}).filter(k => k !== '_analysis_summary_')
|
|
|
|
|
- },
|
|
|
|
|
- detail() {
|
|
|
|
|
- return (this.results && this.selectedSpu && this.results[this.selectedSpu]) || null
|
|
|
|
|
- },
|
|
|
|
|
- currentStage() {
|
|
|
|
|
- return (this.detail && this.detail.current_stage) || '-'
|
|
|
|
|
- },
|
|
|
|
|
- currentStageDurationDays() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- const stats = detail.stage_statistics || {}
|
|
|
|
|
- const cs = detail.current_stage
|
|
|
|
|
- if (cs && stats[cs] && stats[cs].durationDays != null) return stats[cs].durationDays
|
|
|
|
|
- return '-'
|
|
|
|
|
- },
|
|
|
|
|
- stageStats() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- return detail.stage_statistics || {}
|
|
|
|
|
- },
|
|
|
|
|
- displayStageStats() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- const stats = this.stageStats || {}
|
|
|
|
|
- const revenue = detail.revenue_series || detail.smoothed_revenue || []
|
|
|
|
|
- const qty = detail.quantity_series || detail.smoothed_quantity || []
|
|
|
|
|
- const rawDates = detail.date_series || []
|
|
|
|
|
- const labels = rawDates.map(d => formatDate(d))
|
|
|
|
|
-
|
|
|
|
|
- if (detail.is_complete) {
|
|
|
|
|
- const boundaries = (detail.stage_boundaries || []).map(b => formatDate(b.date))
|
|
|
|
|
- const idxs = boundaries.map(d => labels.indexOf(d)).filter(i => i >= 0).sort((a, b) => a - b)
|
|
|
|
|
- if (idxs.length >= 3) {
|
|
|
|
|
- const ranges = [
|
|
|
|
|
- { name: stageOrder[0], start: 0, end: idxs[0] },
|
|
|
|
|
- { name: stageOrder[1], start: idxs[0], end: idxs[1] },
|
|
|
|
|
- { name: stageOrder[2], start: idxs[1], end: idxs[2] },
|
|
|
|
|
- { name: stageOrder[3], start: idxs[2], end: labels.length - 1 }
|
|
|
|
|
- ]
|
|
|
|
|
- const totalRev = revenue.reduce((s, v) => s + (Number(v) || 0), 0)
|
|
|
|
|
- const totalQ = qty.reduce((s, v) => s + (Number(v) || 0), 0)
|
|
|
|
|
- const built = {}
|
|
|
|
|
- ranges.forEach(r => {
|
|
|
|
|
- const revSum = revenue.slice(r.start, r.end + 1).reduce((s, v) => s + (Number(v) || 0), 0)
|
|
|
|
|
- const qtySum = qty.slice(r.start, r.end + 1).reduce((s, v) => s + (Number(v) || 0), 0)
|
|
|
|
|
- const days = Math.max(0, r.end - r.start + 1)
|
|
|
|
|
- built[r.name] = {
|
|
|
|
|
- totalRevenue: revSum,
|
|
|
|
|
- revenuePercentage: totalRev ? (revSum / totalRev * 100) : 0,
|
|
|
|
|
- totalQuantity: qtySum,
|
|
|
|
|
- quantityPercentage: totalQ ? (qtySum / totalQ * 100) : 0,
|
|
|
|
|
- avgDailyRevenue: days ? (revSum / days) : 0,
|
|
|
|
|
- avgDailyQuantity: days ? (qtySum / days) : 0,
|
|
|
|
|
- durationDays: days,
|
|
|
|
|
- startDate: rawDates[r.start],
|
|
|
|
|
- endDate: rawDates[r.end]
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- return built
|
|
|
|
|
- }
|
|
|
|
|
- if (stageOrder.every(s => stats[s])) return stats
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const actual = {}
|
|
|
|
|
- const keys = stageOrder.filter(s => stats[s]).concat(Object.keys(stats).filter(s => stageOrder.indexOf(s) === -1))
|
|
|
|
|
- keys.forEach(k => {
|
|
|
|
|
- const v = stats[k] || {}
|
|
|
|
|
- let duration = v.durationDays
|
|
|
|
|
- const sIdx = labels.indexOf(formatDate(v.startDate))
|
|
|
|
|
- const eIdx = labels.indexOf(formatDate(v.endDate))
|
|
|
|
|
- if (sIdx >= 0 && eIdx >= 0 && eIdx >= sIdx) {
|
|
|
|
|
- duration = Math.max(0, eIdx - sIdx + 1)
|
|
|
|
|
- }
|
|
|
|
|
- actual[k] = Object.assign({}, v, { durationDays: duration })
|
|
|
|
|
- })
|
|
|
|
|
- return actual
|
|
|
|
|
- },
|
|
|
|
|
- breakdownItems() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- const bd = detail.completion_details || {}
|
|
|
|
|
- const mapping = [
|
|
|
|
|
- { key: 'sufficient_time', label: '时间长度≥120天' },
|
|
|
|
|
- { key: 'reasonable_peak_position', label: '峰值位置25%-75%' },
|
|
|
|
|
- { key: 'significant_growth', label: '显著增长(额≥100% 或 量≥80%)' },
|
|
|
|
|
- { key: 'noticeable_decline', label: '明显衰退(额≤-35% 或 量≤-30%)' },
|
|
|
|
|
- { key: 'has_lifecycle_shape', label: '生命周期形状(额CV≥0.3 且 量CV≥0.25)' },
|
|
|
|
|
- { key: 'peak_significance', label: '峰值显著性(≥均值1.8x)' },
|
|
|
|
|
- { key: 'data_quality_check', label: '数据质量(长度≥120,峰值>0,增/退均出现)' },
|
|
|
|
|
- { key: 'cycle_completeness', label: '周期完整性(时间/峰值/增长/衰退/形状)' },
|
|
|
|
|
- { key: 'trend_consistency', label: '趋势一致性(增差≤0.8 或 退差≤0.4 或同向)' }
|
|
|
|
|
- ]
|
|
|
|
|
- return mapping.map(m => {
|
|
|
|
|
- const item = bd[m.key] || {}
|
|
|
|
|
- return {
|
|
|
|
|
- key: m.key,
|
|
|
|
|
- label: m.label,
|
|
|
|
|
- hit: !!item.hit,
|
|
|
|
|
- weight: item.weight != null ? item.weight : '-'
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- },
|
|
|
|
|
- hasFourStages() {
|
|
|
|
|
- const stats = this.stageStats || {}
|
|
|
|
|
- return stageOrder.every(s => !!stats[s])
|
|
|
|
|
- },
|
|
|
|
|
- totalRevenue() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- if (detail.total_revenue != null) return detail.total_revenue
|
|
|
|
|
- return Object.values(this.stageStats || {}).reduce((s, v) => s + (v.totalRevenue || 0), 0)
|
|
|
|
|
- },
|
|
|
|
|
- totalQty() {
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- if (detail.total_quantity != null) return detail.total_quantity
|
|
|
|
|
- return Object.values(this.stageStats || {}).reduce((s, v) => s + (v.totalQuantity || 0), 0)
|
|
|
|
|
- },
|
|
|
|
|
- revenuePct() {
|
|
|
|
|
- return 100
|
|
|
|
|
- },
|
|
|
|
|
- qtyPct() {
|
|
|
|
|
- return 100
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- mounted() {
|
|
|
|
|
- if (!Object.keys(this.results || {}).length) {
|
|
|
|
|
- this.getList()
|
|
|
|
|
- }
|
|
|
|
|
- if (!this.selectedSpu || !this.results[this.selectedSpu]) {
|
|
|
|
|
- const first = this.spuOptions[0] || ''
|
|
|
|
|
- if (first) this.$store.dispatch('spuAnalysis/selectSpu', first)
|
|
|
|
|
- }
|
|
|
|
|
- this.renderTrend()
|
|
|
|
|
- this.renderStageCompare()
|
|
|
|
|
- },
|
|
|
|
|
- beforeDestroy() {
|
|
|
|
|
- if (this.trendChart) this.trendChart.destroy()
|
|
|
|
|
- if (this.stageCompareChart) this.stageCompareChart.destroy()
|
|
|
|
|
- },
|
|
|
|
|
- watch: {
|
|
|
|
|
- detail() {
|
|
|
|
|
- this.renderTrend()
|
|
|
|
|
- this.renderStageCompare()
|
|
|
|
|
- },
|
|
|
|
|
- results() {
|
|
|
|
|
- if (!this.results || !this.results[this.selectedSpu]) {
|
|
|
|
|
- const first = this.spuOptions[0] || ''
|
|
|
|
|
- if (first) this.$store.dispatch('spuAnalysis/selectSpu', first)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- methods: {
|
|
|
|
|
- /** 获取生命周期分析结果 */
|
|
|
|
|
- getList() {
|
|
|
|
|
- getSpuResults().then(response => {
|
|
|
|
|
- const payload = response && response.data ? response.data : null
|
|
|
|
|
- if (payload) {
|
|
|
|
|
- const results = payload || {}
|
|
|
|
|
- this.$store.commit('spuAnalysis/SET_RESULTS', results)
|
|
|
|
|
- const firstSpu = this.pickFirstSpu(results)
|
|
|
|
|
- this.$store.commit('spuAnalysis/SET_SELECTED_SPU', firstSpu)
|
|
|
|
|
- this.$nextTick(() => {
|
|
|
|
|
- this.renderTrend()
|
|
|
|
|
- this.renderStageCompare()
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- }).catch(() => {
|
|
|
|
|
- this.$store.commit('spuAnalysis/SET_RESULTS', {})
|
|
|
|
|
- })
|
|
|
|
|
- },
|
|
|
|
|
- /** 文件选择改变处理 */
|
|
|
|
|
- handleFileChange(file, fileList) {
|
|
|
|
|
- if (this.upload.ignoreFileChange) return
|
|
|
|
|
- if (!fileList || fileList.length === 0) return
|
|
|
|
|
- if (!file || !file.raw) return
|
|
|
|
|
-
|
|
|
|
|
- this.upload.pendingFileName = file.name
|
|
|
|
|
- this.upload.fileName = ''
|
|
|
|
|
- },
|
|
|
|
|
- /** 文件上传前的校验 */
|
|
|
|
|
- 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 isLt500M = file.size / 1024 / 1024 < 500
|
|
|
|
|
-
|
|
|
|
|
- if (!isExcel) {
|
|
|
|
|
- this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- if (!isLt500M) {
|
|
|
|
|
- this.$modal.msgError('上传文件大小不能超过 500MB!')
|
|
|
|
|
- return false
|
|
|
|
|
- }
|
|
|
|
|
- return true
|
|
|
|
|
- },
|
|
|
|
|
- handleFileUploadProgress() {
|
|
|
|
|
- this.upload.isUploading = true
|
|
|
|
|
- },
|
|
|
|
|
- customUpload(options) {
|
|
|
|
|
- const file = options.file
|
|
|
|
|
- this.upload.isUploading = true
|
|
|
|
|
- analyzeSpuFile(file).then(response => {
|
|
|
|
|
- this.upload.isUploading = false
|
|
|
|
|
- if (response && response.success) {
|
|
|
|
|
- const results = response.data || {}
|
|
|
|
|
- this.$store.commit('spuAnalysis/SET_RESULTS', results)
|
|
|
|
|
- const firstSpu = this.pickFirstSpu(results)
|
|
|
|
|
- this.$store.commit('spuAnalysis/SET_SELECTED_SPU', firstSpu)
|
|
|
|
|
- this.$modal.msgSuccess('文件上传并分析成功')
|
|
|
|
|
- this.upload.fileName = this.upload.pendingFileName || file.name
|
|
|
|
|
- this.upload.pendingFileName = ''
|
|
|
|
|
- this.$nextTick(() => {
|
|
|
|
|
- this.renderTrend()
|
|
|
|
|
- this.renderStageCompare()
|
|
|
|
|
- })
|
|
|
|
|
- options.onSuccess(response)
|
|
|
|
|
- } else {
|
|
|
|
|
- this.$modal.msgError(response.msg || '分析失败')
|
|
|
|
|
- options.onError(new Error(response.msg || '分析失败'))
|
|
|
|
|
- }
|
|
|
|
|
- }).catch(error => {
|
|
|
|
|
- this.upload.isUploading = false
|
|
|
|
|
- const msg = (error && error.message) || '文件上传失败,请重试'
|
|
|
|
|
- this.$modal.msgError(msg)
|
|
|
|
|
- options.onError(error)
|
|
|
|
|
- }).finally(() => {
|
|
|
|
|
- if (this.$refs.toolbarUpload) {
|
|
|
|
|
- this.upload.ignoreFileChange = true
|
|
|
|
|
- this.$refs.toolbarUpload.clearFiles()
|
|
|
|
|
- this.$nextTick(() => {
|
|
|
|
|
- this.upload.ignoreFileChange = false
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- },
|
|
|
|
|
- submitUpload() {
|
|
|
|
|
- const target = this.$refs.toolbarUpload
|
|
|
|
|
- const fileList = target && target.uploadFiles ? target.uploadFiles : []
|
|
|
|
|
- if (!fileList || fileList.length === 0) {
|
|
|
|
|
- this.$modal.msgError('请选择要上传的文件')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- target.submit()
|
|
|
|
|
- },
|
|
|
|
|
- formatCurrency,
|
|
|
|
|
- formatDate,
|
|
|
|
|
- formatPercent(n) {
|
|
|
|
|
- if (n == null || Number.isNaN(n)) return '-'
|
|
|
|
|
- return String(Number(n).toFixed(1)) + '%'
|
|
|
|
|
- },
|
|
|
|
|
- stageClass(s) {
|
|
|
|
|
- if (!s) return ''
|
|
|
|
|
- if (s.indexOf('引入') >= 0 || s.indexOf('导入') >= 0) return 'intro'
|
|
|
|
|
- if (s.indexOf('成长') >= 0) return 'growth'
|
|
|
|
|
- if (s.indexOf('成熟') >= 0) return 'maturity'
|
|
|
|
|
- if (s.indexOf('衰退') >= 0) return 'decline'
|
|
|
|
|
- return ''
|
|
|
|
|
- },
|
|
|
|
|
- normalizeDate(dateStr) {
|
|
|
|
|
- if (!dateStr) return ''
|
|
|
|
|
- const match = String(dateStr).match(/(\d{4}-\d{2}-\d{2})/)
|
|
|
|
|
- if (match) return match[1]
|
|
|
|
|
- const parts = String(dateStr).split('T')
|
|
|
|
|
- return parts[0].split(' ')[0]
|
|
|
|
|
- },
|
|
|
|
|
- renderTrend() {
|
|
|
|
|
- const canvas = this.$refs.lifeCycleTrendRef
|
|
|
|
|
- if (!canvas) return
|
|
|
|
|
- const detail = this.detail || {}
|
|
|
|
|
- const revenue = detail.smoothed_revenue || detail.revenue_series || []
|
|
|
|
|
- const qty = detail.smoothed_quantity || detail.quantity_series || []
|
|
|
|
|
- const rawDates = detail.date_series || []
|
|
|
|
|
- const labels = rawDates.map(d => formatDate(d))
|
|
|
|
|
- const boundaries = (detail.stage_boundaries || []).map(b => ({
|
|
|
|
|
- type: b.type,
|
|
|
|
|
- date: b.date ? formatDate(b.date) : null,
|
|
|
|
|
- index: b.index
|
|
|
|
|
- }))
|
|
|
|
|
-
|
|
|
|
|
- const stageColors = {
|
|
|
|
|
- [stageOrder[0]]: 'rgba(59,130,246,0.08)',
|
|
|
|
|
- [stageOrder[1]]: 'rgba(16,185,129,0.10)',
|
|
|
|
|
- [stageOrder[2]]: 'rgba(245,158,11,0.10)',
|
|
|
|
|
- [stageOrder[3]]: 'rgba(239,68,68,0.08)'
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const segments = []
|
|
|
|
|
- const stagesMap = detail.stages_map || []
|
|
|
|
|
-
|
|
|
|
|
- console.log('renderTrend - stagesMap length:', stagesMap.length, 'labels length:', labels.length)
|
|
|
|
|
- console.log('renderTrend - stagesMap sample:', stagesMap.slice(0, 10))
|
|
|
|
|
- console.log('renderTrend - is_complete:', detail.is_complete)
|
|
|
|
|
- console.log('renderTrend - stage_boundaries:', detail.stage_boundaries)
|
|
|
|
|
-
|
|
|
|
|
- if (stagesMap.length > 0 && stagesMap.length === labels.length) {
|
|
|
|
|
- let currentStage = stagesMap[0]
|
|
|
|
|
- let segmentStart = 0
|
|
|
|
|
-
|
|
|
|
|
- for (let i = 1; i < stagesMap.length; i++) {
|
|
|
|
|
- if (stagesMap[i] !== currentStage) {
|
|
|
|
|
- if (currentStage && stageColors[currentStage]) {
|
|
|
|
|
- segments.push({
|
|
|
|
|
- start: segmentStart,
|
|
|
|
|
- end: i - 1,
|
|
|
|
|
- stage: currentStage
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- currentStage = stagesMap[i]
|
|
|
|
|
- segmentStart = i
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (currentStage && stageColors[currentStage] && segmentStart < stagesMap.length) {
|
|
|
|
|
- segments.push({
|
|
|
|
|
- start: segmentStart,
|
|
|
|
|
- end: stagesMap.length - 1,
|
|
|
|
|
- stage: currentStage
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- const statsObj = this.displayStageStats || {}
|
|
|
|
|
- const orderedNames = stageOrder.filter(s => statsObj[s]).concat(Object.keys(statsObj).filter(s => stageOrder.indexOf(s) === -1))
|
|
|
|
|
- orderedNames.forEach(name => {
|
|
|
|
|
- const sIdx = labels.indexOf(formatDate(statsObj[name] && statsObj[name].startDate))
|
|
|
|
|
- const eIdx = labels.indexOf(formatDate(statsObj[name] && statsObj[name].endDate))
|
|
|
|
|
- if (sIdx >= 0 && eIdx >= 0 && eIdx >= sIdx) {
|
|
|
|
|
- segments.push({ start: sIdx, end: eIdx, stage: name })
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log('renderTrend - segments:', segments)
|
|
|
|
|
-
|
|
|
|
|
- const peakRevenueDateRaw = detail.peak_revenue_date
|
|
|
|
|
- const peakQuantityDateRaw = detail.peak_quantity_date
|
|
|
|
|
-
|
|
|
|
|
- let peakRevIdx = -1
|
|
|
|
|
- let peakQtyIdx = -1
|
|
|
|
|
-
|
|
|
|
|
- if (peakRevenueDateRaw && rawDates.length > 0) {
|
|
|
|
|
- const targetDate = this.normalizeDate(peakRevenueDateRaw)
|
|
|
|
|
- peakRevIdx = rawDates.findIndex(d => this.normalizeDate(d) === targetDate)
|
|
|
|
|
-
|
|
|
|
|
- if (peakRevIdx < 0 && detail.revenue_peak_idx != null) {
|
|
|
|
|
- const backendIdx = detail.revenue_peak_idx
|
|
|
|
|
- if (backendIdx >= 0 && backendIdx < rawDates.length) {
|
|
|
|
|
- peakRevIdx = backendIdx
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (peakQuantityDateRaw && rawDates.length > 0) {
|
|
|
|
|
- const targetDate = this.normalizeDate(peakQuantityDateRaw)
|
|
|
|
|
- peakQtyIdx = rawDates.findIndex(d => this.normalizeDate(d) === targetDate)
|
|
|
|
|
-
|
|
|
|
|
- if (peakQtyIdx < 0 && detail.quantity_peak_idx != null) {
|
|
|
|
|
- const backendIdx = detail.quantity_peak_idx
|
|
|
|
|
- if (backendIdx >= 0 && backendIdx < rawDates.length) {
|
|
|
|
|
- peakQtyIdx = backendIdx
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const boundaryIndices = []
|
|
|
|
|
- if (boundaries && boundaries.length > 0) {
|
|
|
|
|
- boundaries.forEach(b => {
|
|
|
|
|
- let idx = -1
|
|
|
|
|
-
|
|
|
|
|
- if (b.index != null && b.index >= 0 && b.index < rawDates.length) {
|
|
|
|
|
- idx = b.index
|
|
|
|
|
- } else if (b.date) {
|
|
|
|
|
- const targetDate = this.normalizeDate(b.date)
|
|
|
|
|
- idx = rawDates.findIndex(d => this.normalizeDate(d) === targetDate)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (idx >= 0 && idx < rawDates.length) {
|
|
|
|
|
- boundaryIndices.push({ index: idx, type: b.type })
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (this.trendChart) this.trendChart.destroy()
|
|
|
|
|
- this.trendChart = new Chart(canvas, {
|
|
|
|
|
- type: 'line',
|
|
|
|
|
- data: {
|
|
|
|
|
- labels: labels,
|
|
|
|
|
- datasets: [
|
|
|
|
|
- {
|
|
|
|
|
- label: '销售额',
|
|
|
|
|
- data: revenue,
|
|
|
|
|
- borderColor: '#3b82f6',
|
|
|
|
|
- backgroundColor: 'rgba(59,130,246,0.15)',
|
|
|
|
|
- lineTension: 0.25,
|
|
|
|
|
- pointRadius: 0,
|
|
|
|
|
- pointHoverRadius: 6,
|
|
|
|
|
- pointHoverBackgroundColor: '#3b82f6',
|
|
|
|
|
- pointHoverBorderColor: '#ffffff',
|
|
|
|
|
- pointHoverBorderWidth: 2
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- label: '销量',
|
|
|
|
|
- data: qty,
|
|
|
|
|
- borderColor: '#64748b',
|
|
|
|
|
- backgroundColor: 'rgba(100,116,139,0.15)',
|
|
|
|
|
- lineTension: 0.25,
|
|
|
|
|
- pointRadius: 0,
|
|
|
|
|
- pointHoverRadius: 6,
|
|
|
|
|
- pointHoverBackgroundColor: '#64748b',
|
|
|
|
|
- pointHoverBorderColor: '#ffffff',
|
|
|
|
|
- pointHoverBorderWidth: 2
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
- },
|
|
|
|
|
- options: {
|
|
|
|
|
- responsive: true,
|
|
|
|
|
- maintainAspectRatio: false,
|
|
|
|
|
- tooltips: {
|
|
|
|
|
- enabled: true,
|
|
|
|
|
- backgroundColor: 'rgba(0,0,0,0.8)',
|
|
|
|
|
- titleFontColor: '#ffffff',
|
|
|
|
|
- bodyFontColor: '#ffffff',
|
|
|
|
|
- borderColor: '#374151',
|
|
|
|
|
- borderWidth: 1,
|
|
|
|
|
- cornerRadius: 6,
|
|
|
|
|
- displayColors: true,
|
|
|
|
|
- callbacks: {
|
|
|
|
|
- title: function(context) {
|
|
|
|
|
- return '日期: ' + context[0].label
|
|
|
|
|
- },
|
|
|
|
|
- label: function(context, data) {
|
|
|
|
|
- const datasetLabel = data.datasets[context.datasetIndex].label
|
|
|
|
|
- const value = context.value
|
|
|
|
|
- return datasetLabel + ': ' + Number(value).toLocaleString()
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- hover: {
|
|
|
|
|
- mode: 'index',
|
|
|
|
|
- intersect: false
|
|
|
|
|
- },
|
|
|
|
|
- scales: {
|
|
|
|
|
- xAxes: [{
|
|
|
|
|
- ticks: {
|
|
|
|
|
- maxRotation: 0,
|
|
|
|
|
- autoSkip: true,
|
|
|
|
|
- maxTicksLimit: 12,
|
|
|
|
|
- callback: function(value, index) {
|
|
|
|
|
- return labels[index]
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }]
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- plugins: [{
|
|
|
|
|
- id: 'stage-backgrounds',
|
|
|
|
|
- beforeDatasetsDraw(chart) {
|
|
|
|
|
- const ctx = chart.ctx
|
|
|
|
|
- const chartArea = chart.chartArea
|
|
|
|
|
- const xScale = chart.scales['x-axis-0']
|
|
|
|
|
-
|
|
|
|
|
- console.log('stage-backgrounds plugin - segments:', segments)
|
|
|
|
|
- console.log('stage-backgrounds plugin - chartArea:', chartArea)
|
|
|
|
|
-
|
|
|
|
|
- if (!segments.length) {
|
|
|
|
|
- console.log('stage-backgrounds plugin - no segments to draw')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- ctx.save()
|
|
|
|
|
- segments.forEach((seg, idx) => {
|
|
|
|
|
- const x1 = xScale.getPixelForValue(seg.start)
|
|
|
|
|
- const x2 = xScale.getPixelForValue(seg.end)
|
|
|
|
|
- const color = stageColors[seg.stage] || 'rgba(0,0,0,0.04)'
|
|
|
|
|
-
|
|
|
|
|
- console.log('Drawing segment ' + idx + ': ' + seg.stage + ', start=' + seg.start + '(' + x1 + 'px), end=' + seg.end + '(' + x2 + 'px), color=' + color)
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = color
|
|
|
|
|
- ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top)
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#374151'
|
|
|
|
|
- ctx.font = 'bold 12px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'left'
|
|
|
|
|
- ctx.fillText(seg.stage, x1 + 4, chartArea.top + 14)
|
|
|
|
|
- })
|
|
|
|
|
- ctx.restore()
|
|
|
|
|
- }
|
|
|
|
|
- }, {
|
|
|
|
|
- id: 'stage-markers',
|
|
|
|
|
- afterDatasetsDraw(chart) {
|
|
|
|
|
- const ctx = chart.ctx
|
|
|
|
|
- const chartArea = chart.chartArea
|
|
|
|
|
- const xScale = chart.scales['x-axis-0']
|
|
|
|
|
- const yScale = chart.scales['y-axis-0']
|
|
|
|
|
- ctx.save()
|
|
|
|
|
-
|
|
|
|
|
- if (boundaryIndices.length > 0) {
|
|
|
|
|
- boundaryIndices.forEach(boundary => {
|
|
|
|
|
- const x = xScale.getPixelForValue(boundary.index)
|
|
|
|
|
-
|
|
|
|
|
- ctx.strokeStyle = '#ef4444'
|
|
|
|
|
- ctx.setLineDash([8, 4])
|
|
|
|
|
- ctx.lineWidth = 2
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.moveTo(x, chartArea.top)
|
|
|
|
|
- ctx.lineTo(x, chartArea.bottom)
|
|
|
|
|
- ctx.stroke()
|
|
|
|
|
- ctx.setLineDash([])
|
|
|
|
|
-
|
|
|
|
|
- const labelText = boundary.type
|
|
|
|
|
- const labelWidth = ctx.measureText(labelText).width + 8
|
|
|
|
|
- const labelHeight = 16
|
|
|
|
|
- const labelX = x - labelWidth / 2
|
|
|
|
|
- const labelY = chartArea.top + 8
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
|
|
|
|
- ctx.fillRect(labelX, labelY, labelWidth, labelHeight)
|
|
|
|
|
-
|
|
|
|
|
- ctx.strokeStyle = '#ef4444'
|
|
|
|
|
- ctx.lineWidth = 1
|
|
|
|
|
- ctx.strokeRect(labelX, labelY, labelWidth, labelHeight)
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#ef4444'
|
|
|
|
|
- ctx.font = 'bold 11px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'center'
|
|
|
|
|
- ctx.fillText(labelText, x, labelY + 12)
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (peakRevIdx >= 0 && peakRevIdx < revenue.length && revenue[peakRevIdx] != null) {
|
|
|
|
|
- const x = xScale.getPixelForValue(peakRevIdx)
|
|
|
|
|
- const y = yScale.getPixelForValue(revenue[peakRevIdx])
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#dc2626'
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.arc(x, y, 5, 0, Math.PI * 2)
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
-
|
|
|
|
|
- ctx.strokeStyle = '#ffffff'
|
|
|
|
|
- ctx.lineWidth = 2
|
|
|
|
|
- ctx.stroke()
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#dc2626'
|
|
|
|
|
- ctx.font = 'bold 12px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'left'
|
|
|
|
|
- ctx.fillText('销售额峰值', x + 10, y - 10)
|
|
|
|
|
-
|
|
|
|
|
- ctx.font = '11px sans-serif'
|
|
|
|
|
- ctx.fillText('¥' + Number(revenue[peakRevIdx]).toLocaleString(), x + 10, y + 2)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (peakQtyIdx >= 0 && peakQtyIdx < qty.length && qty[peakQtyIdx] != null) {
|
|
|
|
|
- const x = xScale.getPixelForValue(peakQtyIdx)
|
|
|
|
|
- const y = yScale.getPixelForValue(qty[peakQtyIdx])
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#2563eb'
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.arc(x, y, 5, 0, Math.PI * 2)
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
-
|
|
|
|
|
- ctx.strokeStyle = '#ffffff'
|
|
|
|
|
- ctx.lineWidth = 2
|
|
|
|
|
- ctx.stroke()
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#2563eb'
|
|
|
|
|
- ctx.font = 'bold 12px sans-serif'
|
|
|
|
|
- ctx.textAlign = 'left'
|
|
|
|
|
- ctx.fillText('销量峰值', x + 10, y - 10)
|
|
|
|
|
-
|
|
|
|
|
- ctx.font = '11px sans-serif'
|
|
|
|
|
- ctx.fillText(String(qty[peakQtyIdx]).toLocaleString() + '件', x + 10, y + 2)
|
|
|
|
|
- }
|
|
|
|
|
- ctx.restore()
|
|
|
|
|
- }
|
|
|
|
|
- }]
|
|
|
|
|
- })
|
|
|
|
|
- },
|
|
|
|
|
- renderStageCompare() {
|
|
|
|
|
- const canvas = this.$refs.stageCompareRef
|
|
|
|
|
- if (!canvas) return
|
|
|
|
|
- const stats = this.displayStageStats || {}
|
|
|
|
|
- const orderedStages = stageOrder.filter(s => stats[s]).concat(Object.keys(stats).filter(s => stageOrder.indexOf(s) === -1))
|
|
|
|
|
- const labels = orderedStages
|
|
|
|
|
- const revenueData = labels.map(s => (stats[s] && stats[s].totalRevenue) || 0)
|
|
|
|
|
- const qtyData = labels.map(s => (stats[s] && stats[s].totalQuantity) || 0)
|
|
|
|
|
- const durationData = labels.map(s => (stats[s] && stats[s].durationDays) || 0)
|
|
|
|
|
-
|
|
|
|
|
- if (this.stageCompareChart) this.stageCompareChart.destroy()
|
|
|
|
|
- this.stageCompareChart = new Chart(canvas, {
|
|
|
|
|
- type: 'bar',
|
|
|
|
|
- data: {
|
|
|
|
|
- labels: labels,
|
|
|
|
|
- datasets: [
|
|
|
|
|
- { label: '销售额', data: revenueData, backgroundColor: 'rgba(59,130,246,0.7)' },
|
|
|
|
|
- { label: '销量', data: qtyData, backgroundColor: 'rgba(16,185,129,0.7)' },
|
|
|
|
|
- { label: '持续天数', data: durationData, backgroundColor: 'rgba(245,158,11,0.7)' }
|
|
|
|
|
- ]
|
|
|
|
|
- },
|
|
|
|
|
- options: {
|
|
|
|
|
- responsive: true,
|
|
|
|
|
- maintainAspectRatio: false,
|
|
|
|
|
- scales: {
|
|
|
|
|
- xAxes: [{ stacked: false }],
|
|
|
|
|
- yAxes: [{ ticks: { beginAtZero: true } }]
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- },
|
|
|
|
|
- formatUploadDate(date) {
|
|
|
|
|
- const d = date instanceof Date ? date : new Date(date)
|
|
|
|
|
- if (Number.isNaN(d.getTime())) return ''
|
|
|
|
|
- const y = d.getFullYear()
|
|
|
|
|
- const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
- const day = String(d.getDate()).padStart(2, '0')
|
|
|
|
|
- return `${y}-${m}-${day}`
|
|
|
|
|
- },
|
|
|
|
|
- exportResults() {
|
|
|
|
|
- if (!this.hasResults) {
|
|
|
|
|
- this.$modal.msgError('暂无可导出的分析结果')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- const payload = JSON.stringify(this.results || {}, null, 2)
|
|
|
|
|
- const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
|
|
|
|
|
- const url = URL.createObjectURL(blob)
|
|
|
|
|
- const a = document.createElement('a')
|
|
|
|
|
- a.href = url
|
|
|
|
|
- a.download = `spu_lifecycle_results_${this.formatUploadDate(new Date())}.json`
|
|
|
|
|
- document.body.appendChild(a)
|
|
|
|
|
- a.click()
|
|
|
|
|
- document.body.removeChild(a)
|
|
|
|
|
- URL.revokeObjectURL(url)
|
|
|
|
|
- },
|
|
|
|
|
- pickFirstSpu(obj) {
|
|
|
|
|
- const keys = Object.keys(obj || {})
|
|
|
|
|
- const first = keys.find(k => k !== '_analysis_summary_')
|
|
|
|
|
- return first || ''
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-</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; }
|
|
|
|
|
-
|
|
|
|
|
-.upload-toolbar {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
- background: #ffffff;
|
|
|
|
|
- border: 1px solid #e6eaf2;
|
|
|
|
|
- border-radius: 8px;
|
|
|
|
|
- padding: 12px 16px;
|
|
|
|
|
- margin-bottom: 16px;
|
|
|
|
|
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-left {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- gap: 12px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-upload ::v-deep .el-upload {
|
|
|
|
|
- display: inline-flex;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-status {
|
|
|
|
|
- font-size: 13px;
|
|
|
|
|
- color: #16a34a;
|
|
|
|
|
- background: #f0fdf4;
|
|
|
|
|
- border: 1px solid #dcfce7;
|
|
|
|
|
- border-radius: 6px;
|
|
|
|
|
- padding: 6px 10px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-status.muted {
|
|
|
|
|
- color: #6b7280;
|
|
|
|
|
- background: #f8fafc;
|
|
|
|
|
- border-color: #e2e8f0;
|
|
|
|
|
-}
|
|
|
|
|
-.p-6 { padding: 24px; }
|
|
|
|
|
-.p-4 { padding: 16px; }
|
|
|
|
|
-.px-3 { padding-left: 12px; padding-right: 12px; }
|
|
|
|
|
-.px-4 { padding-left: 16px; padding-right: 16px; }
|
|
|
|
|
-.py-1 { padding-top: 4px; padding-bottom: 4px; }
|
|
|
|
|
-.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
|
|
|
|
-.py-3 { padding-top: 12px; padding-bottom: 12px; }
|
|
|
|
|
-.pt-2 { padding-top: 8px; }
|
|
|
|
|
-.mb-2 { margin-bottom: 8px; }
|
|
|
|
|
-.mb-4 { margin-bottom: 16px; }
|
|
|
|
|
-.mb-6 { margin-bottom: 24px; }
|
|
|
|
|
-.mb-8 { margin-bottom: 32px; }
|
|
|
|
|
-.mt-1 { margin-top: 4px; }
|
|
|
|
|
-.mt-4 { margin-top: 16px; }
|
|
|
|
|
-.mt-8 { margin-top: 32px; }
|
|
|
|
|
-.ml-2 { margin-left: 8px; }
|
|
|
|
|
-.mr-1 { margin-right: 4px; }
|
|
|
|
|
-
|
|
|
|
|
-.flex { display: flex; }
|
|
|
|
|
-.flex-wrap { flex-wrap: wrap; }
|
|
|
|
|
-.items-center { align-items: center; }
|
|
|
|
|
-.justify-between { justify-content: space-between; }
|
|
|
|
|
-.gap-2 { gap: 8px; }
|
|
|
|
|
-.gap-3 { gap: 12px; }
|
|
|
|
|
-.gap-4 { gap: 16px; }
|
|
|
|
|
-.gap-8 { gap: 20px; }
|
|
|
|
|
-
|
|
|
|
|
-.grid { display: grid; }
|
|
|
|
|
-.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
|
|
|
|
-.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
|
|
-.lg\:col-span-2 { grid-column: span 2 / span 2; }
|
|
|
|
|
-.overflow-x-auto { overflow-x: auto; }
|
|
|
|
|
-.min-w-full { min-width: 100%; }
|
|
|
|
|
-
|
|
|
|
|
-.text-3xl { font-size: 22px; line-height: 1.3; }
|
|
|
|
|
-.text-base { font-size: 16px; line-height: 1.5; }
|
|
|
|
|
-.text-lg { font-size: 18px; line-height: 1.5; }
|
|
|
|
|
-.text-sm { font-size: 14px; line-height: 1.5; }
|
|
|
|
|
-.text-xs { font-size: 12px; line-height: 1.4; }
|
|
|
|
|
-.font-bold { font-weight: 700; }
|
|
|
|
|
-.font-semibold { font-weight: 600; }
|
|
|
|
|
-.font-medium { font-weight: 500; }
|
|
|
|
|
-.text-left { text-align: left; }
|
|
|
|
|
-.uppercase { text-transform: uppercase; }
|
|
|
|
|
-.tracking-wide { letter-spacing: 0.04em; }
|
|
|
|
|
-.tracking-wider { letter-spacing: 0.06em; }
|
|
|
|
|
-.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
-
|
|
|
|
|
-.text-gray-900 { color: #111827; }
|
|
|
|
|
-.text-gray-800 { color: #303133; }
|
|
|
|
|
-.text-gray-700 { color: #606266; }
|
|
|
|
|
-.text-gray-600 { color: #909399; }
|
|
|
|
|
-.text-gray-500 { color: #909399; }
|
|
|
|
|
-.text-gray-400 { color: #9ca3af; }
|
|
|
|
|
-.text-blue-600 { color: #2563eb; }
|
|
|
|
|
-.text-green-700 { color: #15803d; }
|
|
|
|
|
-.text-green-600 { color: #16a34a; }
|
|
|
|
|
-.text-red-700 { color: #b91c1c; }
|
|
|
|
|
-.text-red-600 { color: #dc2626; }
|
|
|
|
|
-.text-orange-600 { color: #ea580c; }
|
|
|
|
|
-.text-yellow-800 { color: #92400e; }
|
|
|
|
|
-
|
|
|
|
|
-.bg-white { background: #ffffff; }
|
|
|
|
|
-.bg-gray-50 { background: #f9fafb; }
|
|
|
|
|
-.bg-gray-500 { background: #6b7280; }
|
|
|
|
|
-.bg-blue-500 { background: #3b82f6; }
|
|
|
|
|
-.bg-green-100 { background: #dcfce7; }
|
|
|
|
|
-.bg-red-100 { background: #fee2e2; }
|
|
|
|
|
-
|
|
|
|
|
-.bg-gradient-to-r { background-image: linear-gradient(to right, var(--from-color), var(--to-color)); }
|
|
|
|
|
-.from-yellow-100 { --from-color: #fef9c3; }
|
|
|
|
|
-.to-amber-100 { --to-color: #fef3c7; }
|
|
|
|
|
-
|
|
|
|
|
-.border { border-width: 1px; border-style: solid; }
|
|
|
|
|
-.border-gray-200 { border-color: #e5e7eb; }
|
|
|
|
|
-.border-gray-300 { border-color: #d1d5db; }
|
|
|
|
|
-.rounded-xl { border-radius: 8px; }
|
|
|
|
|
-.rounded-lg { border-radius: 10px; }
|
|
|
|
|
-.rounded-full { border-radius: 9999px; }
|
|
|
|
|
-.shadow-sm { border: 1px solid #e6eaf2; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }
|
|
|
|
|
-
|
|
|
|
|
-.h-96 { height: 400px; }
|
|
|
|
|
-.h-64 { height: 256px; }
|
|
|
|
|
-.w-3 { width: 12px; }
|
|
|
|
|
-.h-3 { height: 12px; }
|
|
|
|
|
-
|
|
|
|
|
-.space-y-2 > * + * { margin-top: 8px; }
|
|
|
|
|
-.space-y-6 > * + * { margin-top: 24px; }
|
|
|
|
|
-.divide-y > * + * { border-top: 1px solid #e5e7eb; }
|
|
|
|
|
-.divide-gray-200 > * + * { border-top-color: #e5e7eb; }
|
|
|
|
|
-
|
|
|
|
|
-.focus\:outline-none:focus { outline: none; }
|
|
|
|
|
-.focus\:border-transparent:focus { border-color: transparent; }
|
|
|
|
|
-.focus\:ring-2:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); }
|
|
|
|
|
-.focus\:ring-blue-500:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45); }
|
|
|
|
|
-
|
|
|
|
|
-.spu-select {
|
|
|
|
|
- min-width: 200px;
|
|
|
|
|
- background: #ffffff;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-table {
|
|
|
|
|
- border-collapse: separate;
|
|
|
|
|
- border-spacing: 0;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-thead tr {
|
|
|
|
|
- background: #f8fafc;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-thead th:first-child {
|
|
|
|
|
- border-top-left-radius: 8px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-thead th:last-child {
|
|
|
|
|
- border-top-right-radius: 8px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-@media (min-width: 768px) {
|
|
|
|
|
- .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-@media (min-width: 1024px) {
|
|
|
|
|
- .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
|
|
|
- .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/* 进度条基础样式 */
|
|
|
|
|
-.progress-bar { height: 8px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
|
|
|
|
|
-.progress-value { height: 100%; border-radius: 999px; }
|
|
|
|
|
-
|
|
|
|
|
-/* 阶段标签色块 */
|
|
|
|
|
-.lifecycle-stage { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
|
|
|
|
-.lifecycle-stage.intro { background: #dbeafe; color: #1e40af; }
|
|
|
|
|
-.lifecycle-stage.growth { background: #dcfce7; color: #166534; }
|
|
|
|
|
-.lifecycle-stage.maturity { background: #fef3c7; color: #92400e; }
|
|
|
|
|
-.lifecycle-stage.decline { background: #fee2e2; color: #991b1b; }
|
|
|
|
|
-</style>
|
|
|