Ver código fonte

生命周期模块生命周期分析、爆品分析改写为连接数据库

Zhu Jiaqi 3 semanas atrás
pai
commit
70ee518342

+ 41 - 0
src/api/lifecycleDatabase.js

@@ -0,0 +1,41 @@
+import request from '@/utils/request'
+
+export function getLifecycleDatabaseOverview(params) {
+  return request({
+    url: '/lifecycle/database/overview',
+    method: 'get',
+    params
+  })
+}
+
+export function getLifecycleDatabaseSkuResults(params) {
+  return request({
+    url: '/lifecycle/database/sku-results',
+    method: 'get',
+    params
+  })
+}
+
+export function getLifecycleDatabaseSpuResults(params) {
+  return request({
+    url: '/lifecycle/database/spu-results',
+    method: 'get',
+    params
+  })
+}
+
+export function getLifecycleDatabaseHotProductSkuResults(params) {
+  return request({
+    url: '/lifecycle/hotproduct/database/sku-results',
+    method: 'get',
+    params
+  })
+}
+
+export function getLifecycleDatabaseHotProductSpuResults(params) {
+  return request({
+    url: '/lifecycle/hotproduct/database/spu-results',
+    method: 'get',
+    params
+  })
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 314 - 485
src/views/lifecycle/hotProductAnalysis/index.vue


+ 1629 - 0
src/views/lifecycle/hotProductAnalysis/index_python.vue

@@ -0,0 +1,1629 @@
+<template>
+  <div class="app-container">
+    <div class="page-header">
+      <div>
+        <h2><i class="el-icon-s-data"></i> 爆品分析</h2>
+        <p class="page-desc">支持在 SKU 和 SPU 两个维度查看爆品分析结果</p>
+      </div>
+      <div class="analysis-switch">
+        <button
+          v-for="item in analysisTabs"
+          :key="item.value"
+          type="button"
+          class="switch-btn"
+          :class="{ active: activeView === item.value }"
+          @click="switchAnalysis(item.value)"
+        >
+          {{ item.label }}
+        </button>
+      </div>
+    </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="loading"
+          :on-change="handleFileChange"
+          :before-upload="beforeUpload"
+          :auto-upload="false"
+          :show-file-list="false"
+        >
+          <el-button plain>上传文件</el-button>
+        </el-upload>
+        <el-button :loading="loading" type="primary" @click="submitUpload">
+          {{ loading ? '分析中...' : '开始分析' }}
+        </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>
+
+    <div v-if="error" class="error-alert">
+      <i class="el-icon-warning"></i>
+      {{ error }}
+    </div>
+
+    <div class="stats-grid mb-20">
+      <div class="stat-card">
+        <div class="stat-header">
+          <div>
+            <p class="stat-label">总{{ currentConfig.entityName }}数</p>
+            <p class="stat-value">{{ totalEntityCount }}</p>
+          </div>
+          <div class="stat-icon bg-blue">
+            <i class="el-icon-files"></i>
+          </div>
+        </div>
+        <div class="stat-footer">分析对象</div>
+      </div>
+
+      <div class="stat-card">
+        <div class="stat-header">
+          <div>
+            <p class="stat-label">平均爆款系数</p>
+            <p class="stat-value">{{ summary.avg_score || '-' }}</p>
+          </div>
+          <div class="stat-icon bg-green">
+            <i class="el-icon-trend-charts"></i>
+          </div>
+        </div>
+        <div class="stat-footer success">整体表现</div>
+      </div>
+
+      <div class="stat-card">
+        <div class="stat-header">
+          <div>
+            <p class="stat-label">最高系数</p>
+            <p class="stat-value">{{ summary.max_score || '-' }}</p>
+          </div>
+          <div class="stat-icon bg-yellow">
+            <i class="el-icon-trophy"></i>
+          </div>
+        </div>
+        <div class="stat-footer warning">顶尖表现</div>
+      </div>
+
+      <div class="stat-card">
+        <div class="stat-header">
+          <div>
+            <p class="stat-label">超级爆款数</p>
+            <p class="stat-value">{{ levelDistribution['超级爆款'] || 0 }}</p>
+          </div>
+          <div class="stat-icon bg-red">
+            <i class="el-icon-star-on"></i>
+          </div>
+        </div>
+        <div class="stat-footer danger">明星产品</div>
+      </div>
+    </div>
+
+    <div class="charts-grid mb-20">
+      <div class="chart-card">
+        <div class="chart-header">
+          <h3 class="chart-title">爆款等级分布</h3>
+          <div class="chart-subtitle">按{{ currentConfig.entityName }}数量统计</div>
+        </div>
+        <div class="chart-container h-80">
+          <canvas ref="levelDistChartRef"></canvas>
+        </div>
+      </div>
+
+      <div class="chart-card">
+        <div class="chart-header">
+          <h3 class="chart-title">五维指标平均对比</h3>
+          <div class="chart-subtitle">各等级指标表现</div>
+        </div>
+        <div class="chart-container h-80">
+          <canvas ref="metricsCompareChartRef"></canvas>
+        </div>
+      </div>
+    </div>
+
+    <div class="table-card mb-20">
+      <div class="table-header">
+        <h3 class="table-title">{{ currentConfig.entityName }}详情列表</h3>
+        <div class="table-filters">
+          <el-select v-model="filterLevel" placeholder="全部等级" clearable size="small" class="filter-select">
+            <el-option v-for="level in hotProductLevels" :key="level" :value="level" :label="level"></el-option>
+          </el-select>
+          <el-input
+            v-model="searchKeyword"
+            :placeholder="`搜索${currentConfig.entityName}或商品名称...`"
+            size="small"
+            prefix-icon="el-icon-search"
+            class="filter-input"
+            clearable
+          />
+        </div>
+      </div>
+
+      <div class="table-wrapper">
+        <table class="data-table">
+          <thead>
+            <tr>
+              <th @click="sortBy('hotproduct_score')" class="sortable">
+                爆款系数
+                <i :class="getSortIcon('hotproduct_score')"></i>
+              </th>
+              <th>等级</th>
+              <th>{{ currentConfig.entityName }} ID</th>
+              <th>商品名称</th>
+              <th @click="sortBy('total_quantity')" class="sortable">
+                总销量
+                <i :class="getSortIcon('total_quantity')"></i>
+              </th>
+              <th @click="sortBy('total_revenue')" class="sortable">
+                总销售额
+                <i :class="getSortIcon('total_revenue')"></i>
+              </th>
+              <th>操作</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="item in filteredAndSortedItems" :key="getEntityId(item)" class="data-row">
+              <td>
+                <div class="score-cell">
+                  <div class="score-value">{{ item.hotproduct_score || '-' }}</div>
+                  <div class="score-bar">
+                    <div
+                      class="score-progress"
+                      :class="getScoreColorClass(item.hotproduct_score)"
+                      :style="{ width: getScorePercent(item.hotproduct_score) + '%' }"
+                    ></div>
+                  </div>
+                </div>
+              </td>
+              <td>
+                <span class="level-badge" :class="getLevelBadgeClass(item.hotproduct_level)">
+                  {{ item.hotproduct_level || '-' }}
+                </span>
+              </td>
+              <td class="text-sm">{{ getEntityId(item) || '-' }}</td>
+              <td class="text-sm product-title" :title="item.product_title">
+                {{ item.product_title || '-' }}
+              </td>
+              <td class="text-sm">{{ getRawData(item, 'total_quantity') }}</td>
+              <td class="text-sm">{{ formatCurrency(getRawData(item, 'total_revenue')) }}</td>
+              <td>
+                <el-button type="text" size="small" class="action-btn" @click="showDetail(item)">
+                  查看详情
+                </el-button>
+              </td>
+            </tr>
+            <tr v-if="filteredAndSortedItems.length === 0">
+              <td colspan="7" class="empty-state">
+                <i class="el-icon-inbox"></i>
+                <p>未找到符合条件的{{ currentConfig.entityName }}</p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <el-dialog
+      :visible.sync="detailVisible"
+      :title="selectedItem ? selectedItem.product_title : ''"
+      width="80%"
+      top="5vh"
+      custom-class="detail-dialog"
+      :close-on-click-modal="false"
+    >
+      <div v-if="selectedItem" class="detail-content">
+        <div class="detail-info mb-6">
+          <p class="info-label">
+            {{ currentConfig.entityName }} ID:
+            <span class="info-value">{{ getEntityId(selectedItem) }}</span>
+          </p>
+        </div>
+
+        <div class="detail-stats mb-6">
+          <div class="detail-stat-card bg-blue-gradient">
+            <p class="detail-stat-label">爆款系数</p>
+            <p class="detail-stat-value">{{ selectedItem.hotproduct_score || '-' }}</p>
+          </div>
+          <div class="detail-stat-card bg-purple-gradient">
+            <p class="detail-stat-label">爆款等级</p>
+            <p class="detail-stat-value" :class="getLevelTextColor(selectedItem.hotproduct_level)">
+              {{ selectedItem.hotproduct_level || '-' }}
+            </p>
+          </div>
+        </div>
+
+        <div class="detail-section mb-6">
+          <h4 class="section-title">五维指标雷达图</h4>
+          <div class="chart-container h-80">
+            <canvas ref="radarChartRef"></canvas>
+          </div>
+        </div>
+
+        <div class="detail-section mb-6">
+          <h4 class="section-title">指标详情</h4>
+          <div class="metrics-grid">
+            <div v-for="metric in metricDefinitions" :key="metric.key" class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">{{ metric.label }}</span>
+                <span class="metric-score">{{ getMetricValue(selectedItem, metric.key) }}</span>
+              </div>
+              <div class="metric-bar">
+                <div
+                  class="metric-progress"
+                  :class="metric.className"
+                  :style="{ width: getMetricPercentWidth(selectedItem, metric.key) + '%' }"
+                ></div>
+              </div>
+              <p class="metric-weight">权重: {{ metric.weight }}</p>
+            </div>
+          </div>
+        </div>
+
+        <div class="detail-section mb-6">
+          <h4 class="section-title">原始数据</h4>
+          <div class="raw-data-grid">
+            <div class="raw-data-item">
+              <p class="raw-data-label">总销量</p>
+              <p class="raw-data-value">{{ getRawData(selectedItem, 'total_quantity') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">总销售额</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedItem, 'total_revenue')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">平均价格</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedItem, 'avg_price')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">天数跨度</p>
+              <p class="raw-data-value">{{ getRawData(selectedItem, 'days_span') }}天</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">日均销量</p>
+              <p class="raw-data-value">{{ getRawData(selectedItem, 'daily_sales') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">独立买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedItem, 'unique_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">复购买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedItem, 'repurchase_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">退款率</p>
+              <p class="raw-data-value">{{ formatPercent(getRawData(selectedItem, 'refund_rate') * 100) }}</p>
+            </div>
+          </div>
+        </div>
+
+        <div class="advice-box">
+          <h4 class="advice-title">
+            <i class="el-icon-light-rain"></i>
+            运营建议
+          </h4>
+          <p class="advice-content">{{ getOperationAdvice(selectedItem.hotproduct_level) }}</p>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { Chart } from 'chart.js'
+import {
+  analyzeHotProductWithFile,
+  analyzeSpuHotProductWithFile,
+  getHotProductResults,
+  getSpuHotProductResults
+} from '@/api/lifecycle'
+
+const STORAGE_KEY = 'hotproduct_analysis_active_view'
+const CACHE_CONFIG = {
+  sku: {
+    resultsKey: 'hotproduct_analysis_sku_results'
+  },
+  spu: {
+    resultsKey: 'hotproduct_analysis_spu_results'
+  }
+}
+const hotProductLevels = ['超级爆款', '潜力爆款', '常规款', '清货款']
+const metricDefinitions = [
+  { key: 'sales_heat', label: '销售热度(单位时间销量)', weight: '40%', className: 'bg-blue' },
+  { key: 'price_acceptance', label: '价格接受度(实收率)', weight: '30%', className: 'bg-green' },
+  { key: 'refund_stability', label: '退款稳定性(1-退款率)', weight: '10%', className: 'bg-yellow' },
+  { key: 'repurchase_rate', label: '复购热度(复购率)', weight: '10%', className: 'bg-purple' },
+  { key: 'night_burst', label: '夜间爆发力(0-6点占比)', weight: '10%', className: 'bg-indigo' }
+]
+
+const ANALYSIS_CONFIG = {
+  sku: {
+    label: 'SKU分析',
+    entityName: 'SKU',
+    resultKey: 'sku_results',
+    idKey: 'sku_id',
+    summaryCountKey: 'total_sku_count',
+    exportPrefix: 'sku_hotproduct_results',
+    analyze: analyzeHotProductWithFile,
+    fetchResults: getHotProductResults
+  },
+  spu: {
+    label: 'SPU分析',
+    entityName: 'SPU',
+    resultKey: 'spu_results',
+    idKey: 'spu_id',
+    summaryCountKey: 'total_spu_count',
+    exportPrefix: 'spu_hotproduct_results',
+    analyze: analyzeSpuHotProductWithFile,
+    fetchResults: getSpuHotProductResults
+  }
+}
+
+function createUploadState() {
+  return {
+    fileName: '',
+    pendingFileName: '',
+    ignoreFileChange: false
+  }
+}
+
+function loadCachedResults(view) {
+  const config = CACHE_CONFIG[view]
+  try {
+    const rawResults = localStorage.getItem(config.resultsKey)
+    return rawResults ? JSON.parse(rawResults) : null
+  } catch (e) {
+    return null
+  }
+}
+
+export default {
+  name: 'HotProductAnalysis',
+  data() {
+    return {
+      activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
+      loadingByView: {
+        sku: false,
+        spu: false
+      },
+      errorByView: {
+        sku: '',
+        spu: ''
+      },
+      resultsByView: {
+        sku: loadCachedResults('sku'),
+        spu: loadCachedResults('spu')
+      },
+      uploadByView: {
+        sku: createUploadState(),
+        spu: createUploadState()
+      },
+      selectedItem: null,
+      detailVisible: false,
+      filterLevel: '',
+      searchKeyword: '',
+      sortColumn: 'hotproduct_score',
+      sortOrder: 'desc',
+      levelDistChart: null,
+      metricsCompareChart: null,
+      radarChart: null
+    }
+  },
+  computed: {
+    analysisTabs() {
+      return [
+        { label: 'SKU分析', value: 'sku' },
+        { label: 'SPU分析', value: 'spu' }
+      ]
+    },
+    currentConfig() {
+      return ANALYSIS_CONFIG[this.activeView]
+    },
+    upload() {
+      return this.uploadByView[this.activeView]
+    },
+    loading() {
+      return this.loadingByView[this.activeView]
+    },
+    error() {
+      return this.errorByView[this.activeView]
+    },
+    results() {
+      return this.resultsByView[this.activeView]
+    },
+    hasResults() {
+      return this.results !== null && Object.keys(this.results).length > 0
+    },
+    summary() {
+      return (this.results && this.results.summary) || {}
+    },
+    levelDistribution() {
+      return (this.summary && this.summary.level_distribution) || {}
+    },
+    totalEntityCount() {
+      return this.summary[this.currentConfig.summaryCountKey] || this.itemList.length || 0
+    },
+    itemList() {
+      if (!this.results || !this.results[this.currentConfig.resultKey]) return []
+      return Object.values(this.results[this.currentConfig.resultKey])
+    },
+    filteredAndSortedItems() {
+      let list = this.itemList.slice()
+
+      if (this.filterLevel) {
+        list = list.filter(item => item.hotproduct_level === this.filterLevel)
+      }
+
+      if (this.searchKeyword) {
+        const keyword = String(this.searchKeyword).toLowerCase()
+        list = list.filter(item => {
+          const id = String(this.getEntityId(item) || '').toLowerCase()
+          const title = String(item.product_title || '').toLowerCase()
+          return id.includes(keyword) || title.includes(keyword)
+        })
+      }
+
+      const column = this.sortColumn
+      const order = this.sortOrder
+      list.sort((a, b) => {
+        let aValue = 0
+        let bValue = 0
+
+        if (column === 'hotproduct_score') {
+          aValue = Number(a.hotproduct_score) || 0
+          bValue = Number(b.hotproduct_score) || 0
+        } else if (column === 'total_quantity') {
+          aValue = Number(a.raw_data && a.raw_data.total_quantity) || 0
+          bValue = Number(b.raw_data && b.raw_data.total_quantity) || 0
+        } else if (column === 'total_revenue') {
+          aValue = Number(a.raw_data && a.raw_data.total_revenue) || 0
+          bValue = Number(b.raw_data && b.raw_data.total_revenue) || 0
+        }
+
+        return order === 'asc' ? aValue - bValue : bValue - aValue
+      })
+
+      return list
+    },
+    hotProductLevels() {
+      return hotProductLevels
+    },
+    metricDefinitions() {
+      return metricDefinitions
+    }
+  },
+  watch: {
+    activeView() {
+      localStorage.setItem(STORAGE_KEY, this.activeView)
+      this.closeDetail()
+      this.resetUploaderSelection()
+      this.fetchCurrentResults()
+      this.$nextTick(() => {
+        this.renderCharts()
+      })
+    },
+    results() {
+      this.$nextTick(() => {
+        this.renderCharts()
+      })
+    },
+    detailVisible(value) {
+      if (!value && this.radarChart) {
+        this.radarChart.destroy()
+        this.radarChart = null
+      } else if (value && this.selectedItem) {
+        this.$nextTick(() => {
+          this.renderRadarChart()
+        })
+      }
+    }
+  },
+  mounted() {
+    this.fetchCurrentResults()
+    this.$nextTick(() => {
+      this.renderCharts()
+    })
+  },
+  beforeDestroy() {
+    this.destroyCharts()
+  },
+  methods: {
+    switchAnalysis(view) {
+      if (view === this.activeView) return
+      this.activeView = view
+    },
+    setError(value) {
+      this.$set(this.errorByView, this.activeView, value || '')
+    },
+    setViewLoading(view, value) {
+      this.$set(this.loadingByView, view, value)
+    },
+    setViewError(view, value) {
+      this.$set(this.errorByView, view, value || '')
+    },
+    setViewResults(view, value) {
+      this.$set(this.resultsByView, view, value || {})
+      this.persistViewResults(view)
+    },
+    persistViewResults(view) {
+      const config = CACHE_CONFIG[view]
+      try {
+        localStorage.setItem(config.resultsKey, JSON.stringify(this.resultsByView[view] || {}))
+      } catch (e) {}
+    },
+    extractResultsPayload(response) {
+      if (!response) return null
+      if (response.success && response.data) return response.data
+      if (response.data && typeof response.data === 'object') return response.data
+      if (!response.message && !response.msg && typeof response === 'object') return response
+      return null
+    },
+    fetchCurrentResults() {
+      if (this.resultsByView[this.activeView]) return
+
+      this.fetchResultsByView(this.activeView).catch(() => {})
+    },
+    fetchResultsByView(view) {
+      const config = ANALYSIS_CONFIG[view]
+      return config.fetchResults().then(response => {
+        const results = this.extractResultsPayload(response)
+        if (results) {
+          this.setViewResults(view, results)
+        }
+        return results
+      })
+    },
+    refreshAllResults() {
+      return Promise.all(['sku', 'spu'].map(view => this.fetchResultsByView(view).catch(() => null)))
+    },
+    analyzeAllWithFile(file) {
+      const tasks = ['sku', 'spu'].map(view => {
+        const config = ANALYSIS_CONFIG[view]
+        this.setViewLoading(view, true)
+        this.setViewError(view, '')
+        return config.analyze(file).then(response => {
+          if (response && response.success) {
+            this.setViewResults(view, response.data || {})
+            return { view, success: true, response }
+          }
+
+          const message = response && (response.message || response.msg) ? response.message || response.msg : '分析失败'
+          this.setViewError(view, message)
+          return { view, success: false, message }
+        }).catch(error => {
+          const message = (error && error.response && error.response.data && error.response.data.message) ||
+            (error && error.message) ||
+            '文件上传失败,请重试'
+          this.setViewError(view, message)
+          return { view, success: false, error, message }
+        }).finally(() => {
+          this.setViewLoading(view, false)
+        })
+      })
+
+      return Promise.all(tasks)
+    },
+    handleFileChange(file, fileList) {
+      const upload = this.upload
+      if (upload.ignoreFileChange) return
+      if (!fileList || fileList.length === 0 || !file || !file.raw) return
+
+      upload.pendingFileName = file.name
+      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.$message.error('上传文件只能是 xlsx/xls/csv 格式')
+        return false
+      }
+      if (!isLt500M) {
+        this.$message.error('上传文件大小不能超过 500MB')
+        return false
+      }
+      return true
+    },
+    uploadAllHotProductLevels(options) {
+      const file = options.file
+      const upload = this.upload
+
+      return this.analyzeAllWithFile(file).then(results => {
+        const failed = results.filter(item => !item.success)
+        const succeeded = results.filter(item => item.success)
+
+        if (succeeded.length > 0) {
+          ;['sku', 'spu'].forEach(view => {
+            this.uploadByView[view].fileName = upload.pendingFileName || file.name
+            this.uploadByView[view].pendingFileName = ''
+          })
+
+          if (failed.length === 0) {
+            this.$message.success('SKU/SPU爆品分析完成')
+          } else {
+            this.$message.warning(failed.map(item => `${ANALYSIS_CONFIG[item.view].entityName}: ${item.message}`).join(';'))
+          }
+
+          return this.refreshAllResults().then(() => results)
+        }
+
+        const message = failed.map(item => `${ANALYSIS_CONFIG[item.view].entityName}: ${item.message}`).join(';') || '分析失败'
+        this.setError(message)
+        this.$message.error(message)
+        throw new Error(message)
+      }).then(results => {
+        if (results.some(item => item.success)) {
+          this.$nextTick(() => {
+            this.renderCharts()
+          })
+          options.onSuccess({ success: results.every(item => item.success), data: this.resultsByView })
+        }
+      }).catch(error => {
+        options.onError(error)
+      }).finally(() => {
+        this.resetUploaderSelection()
+      })
+    },
+    customUpload(options) {
+      return this.uploadAllHotProductLevels(options)
+    },
+    submitUpload() {
+      const target = this.$refs.toolbarUpload
+      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      if (!fileList || fileList.length === 0) {
+        this.$message.error('请选择要上传的文件')
+        return
+      }
+      target.submit()
+    },
+    resetUploaderSelection() {
+      if (!this.$refs.toolbarUpload) return
+      const upload = this.upload
+      upload.ignoreFileChange = true
+      this.$refs.toolbarUpload.clearFiles()
+      this.$nextTick(() => {
+        upload.ignoreFileChange = false
+      })
+    },
+    renderCharts() {
+      this.renderLevelDistChart()
+      this.renderMetricsCompareChart()
+    },
+    renderLevelDistChart() {
+      if (this.levelDistChart) {
+        this.levelDistChart.destroy()
+        this.levelDistChart = null
+      }
+
+      const canvas = this.$refs.levelDistChartRef
+      if (!canvas) return
+
+      const data = hotProductLevels.map(level => this.levelDistribution[level] || 0)
+      const colors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+
+      this.levelDistChart = new Chart(canvas, {
+        type: 'doughnut',
+        data: {
+          labels: hotProductLevels,
+          datasets: [{
+            data,
+            backgroundColor: colors,
+            borderWidth: 2,
+            borderColor: '#fff'
+          }]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          legend: {
+            position: 'right',
+            labels: {
+              padding: 15,
+              fontSize: 12
+            }
+          },
+          tooltips: {
+            callbacks: {
+              label(tooltipItem, chartData) {
+                const label = chartData.labels[tooltipItem.index] || ''
+                const value = chartData.datasets[0].data[tooltipItem.index] || 0
+                const total = chartData.datasets[0].data.reduce((sum, item) => sum + item, 0)
+                const percentage = total ? (value / total * 100).toFixed(1) : '0.0'
+                return `${label}: ${value} (${percentage}%)`
+              }
+            }
+          }
+        }
+      })
+    },
+    renderMetricsCompareChart() {
+      if (this.metricsCompareChart) {
+        this.metricsCompareChart.destroy()
+        this.metricsCompareChart = null
+      }
+
+      const canvas = this.$refs.metricsCompareChartRef
+      if (!canvas) return
+
+      const metricKeys = metricDefinitions.map(metric => metric.key)
+      const metricNames = metricDefinitions.map(metric => metric.label)
+      const levelMetrics = {}
+
+      hotProductLevels.forEach(level => {
+        const items = this.itemList.filter(item => item.hotproduct_level === level)
+        if (items.length > 0) {
+          levelMetrics[level] = metricKeys.map(key => {
+            const sum = items.reduce((total, item) => total + (Number(item.metrics && item.metrics[key]) || 0), 0)
+            return sum / items.length
+          })
+        } else {
+          levelMetrics[level] = metricKeys.map(() => 0)
+        }
+      })
+
+      const baseColors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+      const datasets = hotProductLevels.map((level, index) => ({
+        label: level,
+        data: levelMetrics[level] || [],
+        backgroundColor: `${baseColors[index]}33`,
+        borderColor: baseColors[index],
+        borderWidth: 2,
+        pointBackgroundColor: baseColors[index]
+      }))
+
+      this.metricsCompareChart = new Chart(canvas, {
+        type: 'radar',
+        data: {
+          labels: metricNames,
+          datasets
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          scale: {
+            ticks: {
+              beginAtZero: true,
+              max: 1,
+              stepSize: 0.2
+            }
+          },
+          legend: {
+            position: 'bottom'
+          }
+        }
+      })
+    },
+    renderRadarChart() {
+      if (!this.selectedItem) return
+      if (this.radarChart) {
+        this.radarChart.destroy()
+        this.radarChart = null
+      }
+
+      const canvas = this.$refs.radarChartRef
+      if (!canvas) return
+
+      const metricNames = metricDefinitions.map(metric => metric.label)
+      const data = metricDefinitions.map(metric => Number(this.selectedItem.metrics && this.selectedItem.metrics[metric.key]) || 0)
+
+      this.radarChart = new Chart(canvas, {
+        type: 'radar',
+        data: {
+          labels: metricNames,
+          datasets: [{
+            label: '指标得分',
+            data,
+            backgroundColor: 'rgba(59, 130, 246, 0.2)',
+            borderColor: 'rgb(59, 130, 246)',
+            borderWidth: 2,
+            pointBackgroundColor: 'rgb(59, 130, 246)',
+            pointBorderColor: '#fff',
+            pointHoverBackgroundColor: '#fff',
+            pointHoverBorderColor: 'rgb(59, 130, 246)'
+          }]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          scale: {
+            ticks: {
+              beginAtZero: true,
+              max: 1,
+              stepSize: 0.2
+            }
+          },
+          legend: {
+            display: false
+          }
+        }
+      })
+    },
+    showDetail(item) {
+      this.selectedItem = item
+      this.detailVisible = true
+    },
+    closeDetail() {
+      this.detailVisible = false
+      this.selectedItem = null
+      if (this.radarChart) {
+        this.radarChart.destroy()
+        this.radarChart = null
+      }
+    },
+    sortBy(column) {
+      if (this.sortColumn === column) {
+        this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
+      } else {
+        this.sortColumn = column
+        this.sortOrder = 'desc'
+      }
+    },
+    getSortIcon(column) {
+      if (this.sortColumn !== column) return 'el-icon-d-caret'
+      return this.sortOrder === 'asc' ? 'el-icon-caret-top' : 'el-icon-caret-bottom'
+    },
+    getEntityId(item) {
+      return item ? item[this.currentConfig.idKey] : ''
+    },
+    getScorePercent(score) {
+      const value = Number(score) || 0
+      return Math.max(0, Math.min(100, value * 100))
+    },
+    getScoreColorClass(score) {
+      const value = Number(score) || 0
+      if (value >= 0.8) return 'score-red'
+      if (value >= 0.6) return 'score-yellow'
+      if (value >= 0.4) return 'score-green'
+      return 'score-gray'
+    },
+    getLevelBadgeClass(level) {
+      const classes = {
+        '超级爆款': 'badge-red',
+        '潜力爆款': 'badge-yellow',
+        '常规款': 'badge-green',
+        '清货款': 'badge-gray'
+      }
+      return classes[level] || 'badge-gray'
+    },
+    getLevelTextColor(level) {
+      const colors = {
+        '超级爆款': 'text-red',
+        '潜力爆款': 'text-yellow',
+        '常规款': 'text-green',
+        '清货款': 'text-gray'
+      }
+      return colors[level] || 'text-gray'
+    },
+    getOperationAdvice(level) {
+      const advice = {
+        '超级爆款': '建议:立即加大库存备货,增加投流预算,抢占市场份额。该类产品需要重点维护和推广。',
+        '潜力爆款': '建议:优化转化链路,提升用户体验,加强营销推广。该类产品有成为爆款的潜力,需要精心培育。',
+        '常规款': '建议:维持现状,持续观察市场反馈。可以尝试小规模优化,但不需要大量投入资源。',
+        '清货款': '建议:考虑清仓促销或直接下架,避免占用库存和运营资源,将精力投入更有潜力的产品。'
+      }
+      return advice[level] || '暂无建议'
+    },
+    getMetricValue(item, key) {
+      if (!item || !item.metrics) return '-'
+      const value = item.metrics[key]
+      if (value == null) return '-'
+      const numberValue = Number(value)
+      return Number.isNaN(numberValue) ? String(value) : numberValue.toFixed(3)
+    },
+    getMetricPercentWidth(item, key) {
+      if (!item || !item.metrics) return 0
+      const value = Number(item.metrics[key]) || 0
+      return Math.max(0, Math.min(100, value * 100))
+    },
+    getRawData(item, key) {
+      if (!item || !item.raw_data) return '-'
+      const value = item.raw_data[key]
+      return value != null ? value : '-'
+    },
+    formatCurrency(value) {
+      if (value == null || value === '-') return '-'
+      const numberValue = Number(value)
+      if (Number.isNaN(numberValue)) return '-'
+      return `¥${numberValue.toFixed(2)}`
+    },
+    formatPercent(value) {
+      if (value == null || value === '-') return '-'
+      const numberValue = Number(value)
+      if (Number.isNaN(numberValue)) return '-'
+      return `${numberValue.toFixed(2)}%`
+    },
+    exportResults() {
+      if (!this.hasResults) {
+        this.$message.error('暂无可导出的分析结果')
+        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 link = document.createElement('a')
+      link.href = url
+      link.download = `${this.currentConfig.exportPrefix}_${new Date().toISOString().slice(0, 10)}.json`
+      document.body.appendChild(link)
+      link.click()
+      document.body.removeChild(link)
+      URL.revokeObjectURL(url)
+    },
+    destroyCharts() {
+      if (this.levelDistChart) this.levelDistChart.destroy()
+      if (this.metricsCompareChart) this.metricsCompareChart.destroy()
+      if (this.radarChart) this.radarChart.destroy()
+      this.levelDistChart = null
+      this.metricsCompareChart = null
+      this.radarChart = null
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.app-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  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;
+  }
+}
+
+.analysis-switch {
+  display: inline-flex;
+  padding: 4px;
+  border-radius: 10px;
+  background: #f3f6fb;
+  border: 1px solid #dce6f2;
+}
+
+.switch-btn {
+  border: none;
+  background: transparent;
+  color: #606266;
+  padding: 8px 16px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.switch-btn.active {
+  background: #ffffff;
+  color: #2563eb;
+  box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
+}
+
+.mb-20 { margin-bottom: 20px; }
+.mb-6 { margin-bottom: 24px; }
+
+.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;
+}
+
+.error-alert {
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  padding: 12px 16px;
+  color: #dc2626;
+  margin-bottom: 16px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  i {
+    font-size: 18px;
+  }
+}
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+  gap: 20px;
+}
+
+.stat-card {
+  background: #ffffff;
+  border: 1px solid #e6eaf2;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+  transition: transform 0.2s ease, box-shadow 0.2s ease;
+
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 4px 8px rgba(15, 23, 42, 0.1);
+  }
+}
+
+.stat-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 12px;
+}
+
+.stat-label {
+  font-size: 12px;
+  color: #909399;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin-bottom: 8px;
+}
+
+.stat-value {
+  font-size: 28px;
+  font-weight: 700;
+  color: #303133;
+  margin: 0;
+}
+
+.stat-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+
+  &.bg-blue {
+    background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
+    color: #2563eb;
+  }
+
+  &.bg-green {
+    background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
+    color: #059669;
+  }
+
+  &.bg-yellow {
+    background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+    color: #d97706;
+  }
+
+  &.bg-red {
+    background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+    color: #dc2626;
+  }
+}
+
+.stat-footer {
+  font-size: 13px;
+  color: #606266;
+
+  &.success { color: #16a34a; }
+  &.warning { color: #d97706; }
+  &.danger { color: #dc2626; }
+}
+
+.charts-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+  gap: 20px;
+}
+
+.chart-card {
+  background: #ffffff;
+  border: 1px solid #e6eaf2;
+  border-radius: 8px;
+  padding: 24px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.chart-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+}
+
+.chart-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0;
+}
+
+.chart-subtitle {
+  font-size: 13px;
+  color: #909399;
+}
+
+.chart-container {
+  position: relative;
+
+  &.h-80 {
+    height: 320px;
+  }
+}
+
+.table-card {
+  background: #ffffff;
+  border: 1px solid #e6eaf2;
+  border-radius: 8px;
+  padding: 24px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.table-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.table-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0;
+}
+
+.table-filters {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.filter-select {
+  width: 140px;
+}
+
+.filter-input {
+  width: 260px;
+}
+
+.table-wrapper {
+  overflow-x: auto;
+}
+
+.data-table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+
+  thead {
+    background: #f8fafc;
+
+    th {
+      padding: 12px 16px;
+      text-align: left;
+      font-size: 12px;
+      font-weight: 600;
+      color: #606266;
+      text-transform: uppercase;
+      letter-spacing: 0.05em;
+      border-bottom: 1px solid #e5e7eb;
+
+      &:first-child {
+        border-top-left-radius: 8px;
+      }
+
+      &:last-child {
+        border-top-right-radius: 8px;
+      }
+
+      &.sortable {
+        cursor: pointer;
+        user-select: none;
+
+        &:hover {
+          background: #f1f5f9;
+        }
+
+        i {
+          margin-left: 4px;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+
+  tbody {
+    tr.data-row {
+      transition: background-color 0.2s;
+
+      &:hover {
+        background: #f9fafb;
+      }
+
+      td {
+        padding: 16px;
+        border-bottom: 1px solid #e5e7eb;
+        font-size: 14px;
+        color: #303133;
+
+        &.text-sm {
+          font-size: 13px;
+        }
+
+        &.product-title {
+          max-width: 300px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+
+    tr:last-child td {
+      border-bottom: none;
+    }
+  }
+}
+
+.score-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.score-value {
+  font-weight: 700;
+  min-width: 40px;
+}
+
+.score-bar {
+  flex: 1;
+  height: 8px;
+  background: #f3f4f6;
+  border-radius: 999px;
+  overflow: hidden;
+  max-width: 80px;
+}
+
+.score-progress {
+  height: 100%;
+  border-radius: 999px;
+  transition: width 0.3s;
+
+  &.score-red { background: #ef4444; }
+  &.score-yellow { background: #f59e0b; }
+  &.score-green { background: #10b981; }
+  &.score-gray { background: #6b7280; }
+}
+
+.level-badge {
+  display: inline-block;
+  padding: 4px 12px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 600;
+
+  &.badge-red {
+    background: #fee2e2;
+    color: #991b1b;
+  }
+
+  &.badge-yellow {
+    background: #fef3c7;
+    color: #92400e;
+  }
+
+  &.badge-green {
+    background: #d1fae5;
+    color: #065f46;
+  }
+
+  &.badge-gray {
+    background: #f3f4f6;
+    color: #374151;
+  }
+}
+
+.action-btn {
+  color: #409eff;
+  font-weight: 500;
+
+  &:hover {
+    color: #66b1ff;
+  }
+}
+
+.empty-state {
+  padding: 60px 20px !important;
+  text-align: center;
+  color: #909399;
+
+  i {
+    font-size: 48px;
+    display: block;
+    margin-bottom: 12px;
+  }
+
+  p {
+    margin: 0;
+    font-size: 14px;
+  }
+}
+
+.detail-dialog ::v-deep .el-dialog__header {
+  border-bottom: 1px solid #e5e7eb;
+  padding: 20px 24px;
+}
+
+.detail-dialog ::v-deep .el-dialog__body {
+  padding: 24px;
+}
+
+.detail-content {
+  max-height: 70vh;
+  overflow-y: auto;
+}
+
+.detail-info {
+  .info-label {
+    font-size: 14px;
+    color: #606266;
+    margin: 0;
+  }
+
+  .info-value {
+    font-weight: 600;
+    color: #303133;
+  }
+}
+
+.detail-stats {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 16px;
+}
+
+.detail-stat-card {
+  padding: 20px;
+  border-radius: 8px;
+
+  &.bg-blue-gradient {
+    background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
+  }
+
+  &.bg-purple-gradient {
+    background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
+  }
+}
+
+.detail-stat-label {
+  font-size: 13px;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.detail-stat-value {
+  font-size: 32px;
+  font-weight: 700;
+  color: #303133;
+  margin: 0;
+
+  &.text-red { color: #dc2626; }
+  &.text-yellow { color: #d97706; }
+  &.text-green { color: #059669; }
+  &.text-gray { color: #6b7280; }
+}
+
+.detail-section {
+  .section-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 16px;
+  }
+}
+
+.metrics-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+  gap: 16px;
+}
+
+.metric-item {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 16px;
+  background: #f9fafb;
+}
+
+.metric-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  gap: 12px;
+}
+
+.metric-name {
+  font-size: 13px;
+  color: #606266;
+}
+
+.metric-score {
+  font-size: 16px;
+  font-weight: 700;
+  color: #303133;
+  white-space: nowrap;
+}
+
+.metric-bar {
+  height: 8px;
+  background: #e5e7eb;
+  border-radius: 999px;
+  overflow: hidden;
+  margin-bottom: 8px;
+}
+
+.metric-progress {
+  height: 100%;
+  border-radius: 999px;
+
+  &.bg-blue { background: #3b82f6; }
+  &.bg-green { background: #10b981; }
+  &.bg-yellow { background: #f59e0b; }
+  &.bg-purple { background: #8b5cf6; }
+  &.bg-indigo { background: #6366f1; }
+}
+
+.metric-weight {
+  font-size: 12px;
+  color: #909399;
+  margin: 0;
+}
+
+.raw-data-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 16px;
+}
+
+.raw-data-item {
+  background: #f9fafb;
+  border-radius: 8px;
+  padding: 16px;
+}
+
+.raw-data-label {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+
+.raw-data-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: #303133;
+  margin: 0;
+}
+
+.advice-box {
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  padding: 16px;
+}
+
+.advice-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1e40af;
+  margin-bottom: 8px;
+
+  i {
+    margin-right: 8px;
+  }
+}
+
+.advice-content {
+  font-size: 14px;
+  color: #1e3a8a;
+  margin: 0;
+  line-height: 1.6;
+}
+
+@media (max-width: 767px) {
+  .page-header,
+  .upload-toolbar,
+  .table-header,
+  .chart-header {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .analysis-switch,
+  .toolbar-left,
+  .table-filters {
+    width: 100%;
+  }
+
+  .switch-btn {
+    flex: 1;
+  }
+
+  .toolbar-left,
+  .table-filters {
+    flex-wrap: wrap;
+  }
+
+  .filter-select,
+  .filter-input {
+    width: 100%;
+  }
+
+  .charts-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .detail-stats {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

Diferenças do arquivo suprimidas por serem muito extensas
+ 252 - 624
src/views/lifecycle/lifecycleAnalysis/index.vue


+ 1318 - 0
src/views/lifecycle/lifecycleAnalysis/index_python.vue

@@ -0,0 +1,1318 @@
+<template>
+  <div class="app-container">
+    <div class="page-header">
+      <div>
+        <h2><i class="el-icon-data-analysis"></i> 生命周期分析</h2>
+        <p class="page-desc">支持在 SKU 和 SPU 两个维度查看生命周期分析结果</p>
+      </div>
+      <div class="analysis-switch">
+        <button
+          v-for="item in analysisTabs"
+          :key="item.value"
+          type="button"
+          class="switch-btn"
+          :class="{ active: activeView === item.value }"
+          @click="switchAnalysis(item.value)"
+        >
+          {{ item.label }}
+        </button>
+      </div>
+    </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>
+
+    <template v-if="hasResults && detail">
+      <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">选择{{ currentConfig.entityName }}:</label>
+            <select
+              v-model="selectedValue"
+              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 entity-select"
+            >
+              <option v-for="item in entityOptions" :key="item" :value="item">{{ item }}</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">{{ currentConfig.entityDisplayLabel }}</p>
+            <p class="text-lg font-medium text-gray-800 truncate">{{ selectedValue }}</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">{{ currentConfig.entityName }}生命周期趋势图</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>
+            不完整生命周期{{ currentConfig.entityName }} - 完整性评估得分:{{ 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>
+    </template>
+
+    <div v-else class="empty-state shadow-sm">
+      <i class="el-icon-upload2"></i>
+      <p>暂无{{ currentConfig.entityName }}生命周期分析结果,请先上传文件并开始分析。</p>
+    </div>
+  </div>
+</template>
+
+<script>
+import { analyzeFile, analyzeSpuFile, getResults, getSpuResults } from '@/api/lifecycle'
+import { Chart } from 'chart.js'
+import { formatCurrency, formatDate } from '../../../utils/format'
+
+const STORAGE_KEY = 'lifecycle_analysis_active_view'
+const CACHE_CONFIG = {
+  sku: {
+    resultsKey: 'analysis_results',
+    selectedKey: 'analysis_selectedSku'
+  },
+  spu: {
+    resultsKey: 'spu_analysis_results',
+    selectedKey: 'spu_analysis_selectedSpu'
+  }
+}
+const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
+const breakdownMapping = [
+  { 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 或同向)' }
+]
+
+const ANALYSIS_CONFIG = {
+  sku: {
+    label: 'SKU分析',
+    entityName: 'SKU',
+    entityDisplayLabel: 'SKU编码',
+    maxSizeMB: 300,
+    exportPrefix: 'sku_lifecycle_results',
+    fetchResults: getResults,
+    analyze: analyzeFile
+  },
+  spu: {
+    label: 'SPU分析',
+    entityName: 'SPU',
+    entityDisplayLabel: 'SPU名称',
+    maxSizeMB: 500,
+    exportPrefix: 'spu_lifecycle_results',
+    fetchResults: getSpuResults,
+    analyze: analyzeSpuFile
+  }
+}
+
+function loadCachedAnalysis(view) {
+  const config = CACHE_CONFIG[view]
+  try {
+    const rawResults = localStorage.getItem(config.resultsKey)
+    return {
+      results: rawResults ? JSON.parse(rawResults) : {},
+      selected: localStorage.getItem(config.selectedKey) || ''
+    }
+  } catch (e) {
+    return { results: {}, selected: '' }
+  }
+}
+
+function createUploadState() {
+  return {
+    isUploading: false,
+    fileName: '',
+    pendingFileName: '',
+    ignoreFileChange: false
+  }
+}
+
+export default {
+  name: 'LifecycleAnalysis',
+  data() {
+    return {
+      activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
+      trendChart: null,
+      stageCompareChart: null,
+      cache: {
+        sku: loadCachedAnalysis('sku'),
+        spu: loadCachedAnalysis('spu')
+      },
+      uploads: {
+        sku: createUploadState(),
+        spu: createUploadState()
+      }
+    }
+  },
+  computed: {
+    analysisTabs() {
+      return [
+        { label: 'SKU分析', value: 'sku' },
+        { label: 'SPU分析', value: 'spu' }
+      ]
+    },
+    currentConfig() {
+      return ANALYSIS_CONFIG[this.activeView]
+    },
+    upload() {
+      return this.uploads[this.activeView]
+    },
+    currentCache() {
+      return this.cache[this.activeView] || { results: {}, selected: '' }
+    },
+    results() {
+      return this.currentCache.results || {}
+    },
+    hasResults() {
+      return Object.keys(this.results || {}).length > 0
+    },
+    selectedValue: {
+      get() {
+        return this.currentCache.selected || ''
+      },
+      set(value) {
+        this.setSelectedValue(value)
+      }
+    },
+    entityOptions() {
+      return Object.keys(this.results || {}).filter(key => key !== '_analysis_summary_')
+    },
+    detail() {
+      return (this.results && this.selectedValue && this.results[this.selectedValue]) || null
+    },
+    currentStage() {
+      return (this.detail && this.detail.current_stage) || '-'
+    },
+    currentStageDurationDays() {
+      const stats = (this.detail && this.detail.stage_statistics) || {}
+      const currentStage = this.detail && this.detail.current_stage
+      if (currentStage && stats[currentStage] && stats[currentStage].durationDays != null) {
+        return stats[currentStage].durationDays
+      }
+      return '-'
+    },
+    stageStats() {
+      return (this.detail && this.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(date => formatDate(date))
+
+      if (detail.is_complete) {
+        const boundaries = (detail.stage_boundaries || []).map(item => formatDate(item.date))
+        const indices = boundaries
+          .map(date => labels.indexOf(date))
+          .filter(index => index >= 0)
+          .sort((a, b) => a - b)
+
+        if (indices.length >= 3) {
+          const ranges = [
+            { name: stageOrder[0], start: 0, end: indices[0] },
+            { name: stageOrder[1], start: indices[0], end: indices[1] },
+            { name: stageOrder[2], start: indices[1], end: indices[2] },
+            { name: stageOrder[3], start: indices[2], end: labels.length - 1 }
+          ]
+          const totalRevenue = revenue.reduce((sum, value) => sum + (Number(value) || 0), 0)
+          const totalQty = qty.reduce((sum, value) => sum + (Number(value) || 0), 0)
+          const built = {}
+
+          ranges.forEach(range => {
+            const revenueSum = revenue.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
+            const qtySum = qty.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
+            const days = Math.max(0, range.end - range.start + 1)
+
+            built[range.name] = {
+              totalRevenue: revenueSum,
+              revenuePercentage: totalRevenue ? revenueSum / totalRevenue * 100 : 0,
+              totalQuantity: qtySum,
+              quantityPercentage: totalQty ? qtySum / totalQty * 100 : 0,
+              avgDailyRevenue: days ? revenueSum / days : 0,
+              avgDailyQuantity: days ? qtySum / days : 0,
+              durationDays: days,
+              startDate: rawDates[range.start],
+              endDate: rawDates[range.end]
+            }
+          })
+
+          return built
+        }
+
+        if (stageOrder.every(stage => stats[stage])) {
+          return stats
+        }
+      }
+
+      const actual = {}
+      const keys = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+
+      keys.forEach(key => {
+        const value = stats[key] || {}
+        let duration = value.durationDays
+        const startIndex = labels.indexOf(formatDate(value.startDate))
+        const endIndex = labels.indexOf(formatDate(value.endDate))
+        if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
+          duration = Math.max(0, endIndex - startIndex + 1)
+        }
+        actual[key] = Object.assign({}, value, { durationDays: duration })
+      })
+
+      return actual
+    },
+    breakdownItems() {
+      const detail = this.detail || {}
+      const completionDetails = detail.completion_details || {}
+      return breakdownMapping.map(item => {
+        const current = completionDetails[item.key] || {}
+        return {
+          key: item.key,
+          label: item.label,
+          hit: !!current.hit,
+          weight: current.weight != null ? current.weight : '-'
+        }
+      })
+    },
+    hasFourStages() {
+      return stageOrder.every(stage => !!this.stageStats[stage])
+    },
+    totalRevenue() {
+      if (this.detail && this.detail.total_revenue != null) return this.detail.total_revenue
+      return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalRevenue || 0), 0)
+    },
+    totalQty() {
+      if (this.detail && this.detail.total_quantity != null) return this.detail.total_quantity
+      return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalQuantity || 0), 0)
+    },
+    revenuePct() {
+      return 100
+    },
+    qtyPct() {
+      return 100
+    }
+  },
+  mounted() {
+    this.initCurrentView()
+  },
+  beforeDestroy() {
+    this.destroyCharts()
+  },
+  watch: {
+    activeView() {
+      localStorage.setItem(STORAGE_KEY, this.activeView)
+      this.resetUploaderSelection()
+      this.initCurrentView()
+    },
+    detail() {
+      this.$nextTick(() => {
+        this.renderTrend()
+        this.renderStageCompare()
+      })
+    },
+    results() {
+      this.ensureCurrentSelection()
+    }
+  },
+  methods: {
+    formatCurrency,
+    formatDate,
+    switchAnalysis(view) {
+      if (view === this.activeView) return
+      this.activeView = view
+    },
+    initCurrentView() {
+      if (!this.hasResults) {
+        this.getList()
+        return
+      }
+
+      this.ensureCurrentSelection()
+      this.$nextTick(() => {
+        this.renderTrend()
+        this.renderStageCompare()
+      })
+    },
+    getCurrentUploadState() {
+      return this.uploads[this.activeView]
+    },
+    persistCurrentCache() {
+      const config = CACHE_CONFIG[this.activeView]
+      const cache = this.currentCache
+      try {
+        localStorage.setItem(config.resultsKey, JSON.stringify(cache.results || {}))
+        localStorage.setItem(config.selectedKey, cache.selected || '')
+      } catch (e) {}
+    },
+    setCurrentResults(results) {
+      this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
+        results: results || {}
+      }))
+      this.persistCurrentCache()
+    },
+    setSelectedValue(value) {
+      this.$set(this.cache, this.activeView, Object.assign({}, this.currentCache, {
+        selected: value || ''
+      }))
+      this.persistCurrentCache()
+    },
+    extractResultsPayload(response) {
+      if (!response) return null
+      if (response.success && response.data) return response.data
+      if (response.code === 200 && response.data) return response.data
+      if (response.data && typeof response.data === 'object') return response.data
+      if (!response.message && !response.msg && typeof response === 'object') return response
+      return null
+    },
+    ensureCurrentSelection() {
+      if (!this.hasResults) {
+        this.selectedValue = ''
+        this.destroyCharts()
+        return
+      }
+
+      if (!this.selectedValue || !this.results[this.selectedValue]) {
+        const first = this.pickFirstValue(this.results)
+        if (first) {
+          this.selectedValue = first
+        }
+      }
+    },
+    getList() {
+      this.currentConfig.fetchResults().then(response => {
+        const results = this.extractResultsPayload(response)
+        if (results) {
+          this.setCurrentResults(results)
+          const first = this.pickFirstValue(results)
+          this.setSelectedValue(first)
+          this.$nextTick(() => {
+            this.renderTrend()
+            this.renderStageCompare()
+          })
+        } else {
+          this.setCurrentResults({})
+        }
+      }).catch(() => {
+        this.setCurrentResults({})
+        this.destroyCharts()
+      })
+    },
+    handleFileChange(file, fileList) {
+      const upload = this.getCurrentUploadState()
+      if (upload.ignoreFileChange) return
+      if (!fileList || fileList.length === 0 || !file || !file.raw) return
+
+      upload.pendingFileName = file.name
+      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 isLtLimit = file.size / 1024 / 1024 < this.currentConfig.maxSizeMB
+
+      if (!isExcel) {
+        this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式')
+        return false
+      }
+      if (!isLtLimit) {
+        this.$modal.msgError(`上传文件大小不能超过 ${this.currentConfig.maxSizeMB}MB`)
+        return false
+      }
+      return true
+    },
+    customUpload(options) {
+      const file = options.file
+      const upload = this.getCurrentUploadState()
+      upload.isUploading = true
+
+      this.currentConfig.analyze(file).then(response => {
+        upload.isUploading = false
+        if (response && response.success) {
+          const results = response.data || {}
+          this.setCurrentResults(results)
+          const first = this.pickFirstValue(results)
+          this.setSelectedValue(first)
+          upload.fileName = upload.pendingFileName || file.name
+          upload.pendingFileName = ''
+          this.$modal.msgSuccess(`${this.currentConfig.entityName}生命周期分析完成`)
+          this.$nextTick(() => {
+            this.renderTrend()
+            this.renderStageCompare()
+          })
+          options.onSuccess(response)
+        } else {
+          const message = response && (response.msg || response.message) ? response.msg || response.message : '分析失败'
+          this.$modal.msgError(message)
+          options.onError(new Error(message))
+        }
+      }).catch(error => {
+        upload.isUploading = false
+        const message = (error && error.message) || '文件上传失败,请重试'
+        this.$modal.msgError(message)
+        options.onError(error)
+      }).finally(() => {
+        this.resetUploaderSelection()
+      })
+    },
+    submitUpload() {
+      const target = this.$refs.toolbarUpload
+      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      if (!fileList || fileList.length === 0) {
+        this.$modal.msgError('请选择要上传的文件')
+        return
+      }
+      target.submit()
+    },
+    resetUploaderSelection() {
+      if (!this.$refs.toolbarUpload) return
+      const upload = this.getCurrentUploadState()
+      upload.ignoreFileChange = true
+      this.$refs.toolbarUpload.clearFiles()
+      this.$nextTick(() => {
+        upload.ignoreFileChange = false
+      })
+    },
+    formatPercent(value) {
+      if (value == null || Number.isNaN(value)) return '-'
+      return `${Number(value).toFixed(1)}%`
+    },
+    stageClass(stage) {
+      if (!stage) return ''
+      if (stage.indexOf('引入') >= 0 || stage.indexOf('导入') >= 0) return 'intro'
+      if (stage.indexOf('成长') >= 0) return 'growth'
+      if (stage.indexOf('成熟') >= 0) return 'maturity'
+      if (stage.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]
+    },
+    destroyCharts() {
+      if (this.trendChart) {
+        this.trendChart.destroy()
+        this.trendChart = null
+      }
+      if (this.stageCompareChart) {
+        this.stageCompareChart.destroy()
+        this.stageCompareChart = null
+      }
+    },
+    renderTrend() {
+      const canvas = this.$refs.lifeCycleTrendRef
+      if (!canvas || !this.detail) {
+        if (this.trendChart) {
+          this.trendChart.destroy()
+          this.trendChart = null
+        }
+        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(date => formatDate(date))
+      const boundaries = (detail.stage_boundaries || []).map(boundary => ({
+        type: boundary.type,
+        date: boundary.date ? formatDate(boundary.date) : null,
+        index: boundary.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 || []
+
+      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 stats = this.displayStageStats || {}
+        const orderedStages = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+        orderedStages.forEach(stage => {
+          const startIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].startDate))
+          const endIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].endDate))
+          if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
+            segments.push({ start: startIndex, end: endIndex, stage })
+          }
+        })
+      }
+
+      const peakRevenueDate = detail.peak_revenue_date
+      const peakQtyDate = detail.peak_quantity_date
+      let peakRevenueIndex = -1
+      let peakQtyIndex = -1
+
+      if (peakRevenueDate && rawDates.length > 0) {
+        const targetDate = this.normalizeDate(peakRevenueDate)
+        peakRevenueIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        if (peakRevenueIndex < 0 && detail.revenue_peak_idx != null && detail.revenue_peak_idx < rawDates.length) {
+          peakRevenueIndex = detail.revenue_peak_idx
+        }
+      }
+
+      if (peakQtyDate && rawDates.length > 0) {
+        const targetDate = this.normalizeDate(peakQtyDate)
+        peakQtyIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        if (peakQtyIndex < 0 && detail.quantity_peak_idx != null && detail.quantity_peak_idx < rawDates.length) {
+          peakQtyIndex = detail.quantity_peak_idx
+        }
+      }
+
+      const boundaryIndices = []
+      boundaries.forEach(boundary => {
+        let index = -1
+        if (boundary.index != null && boundary.index >= 0 && boundary.index < rawDates.length) {
+          index = boundary.index
+        } else if (boundary.date) {
+          const targetDate = this.normalizeDate(boundary.date)
+          index = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        }
+
+        if (index >= 0 && index < rawDates.length) {
+          boundaryIndices.push({ index, type: boundary.type })
+        }
+      })
+
+      if (this.trendChart) this.trendChart.destroy()
+      this.trendChart = new Chart(canvas, {
+        type: 'line',
+        data: {
+          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(context) {
+                return `日期: ${context[0].label}`
+              },
+              label(context, data) {
+                const datasetLabel = data.datasets[context.datasetIndex].label
+                return `${datasetLabel}: ${Number(context.value).toLocaleString()}`
+              }
+            }
+          },
+          hover: {
+            mode: 'index',
+            intersect: false
+          },
+          scales: {
+            xAxes: [{
+              ticks: {
+                maxRotation: 0,
+                autoSkip: true,
+                maxTicksLimit: 12,
+                callback(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']
+
+            if (!segments.length) return
+
+            ctx.save()
+            segments.forEach(segment => {
+              const startX = xScale.getPixelForValue(segment.start)
+              const endX = xScale.getPixelForValue(segment.end)
+              const color = stageColors[segment.stage] || 'rgba(0,0,0,0.04)'
+
+              ctx.fillStyle = color
+              ctx.fillRect(startX, chartArea.top, endX - startX, chartArea.bottom - chartArea.top)
+
+              ctx.fillStyle = '#374151'
+              ctx.font = 'bold 12px sans-serif'
+              ctx.textAlign = 'left'
+              ctx.fillText(segment.stage, startX + 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()
+
+            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 (peakRevenueIndex >= 0 && peakRevenueIndex < revenue.length && revenue[peakRevenueIndex] != null) {
+              const x = xScale.getPixelForValue(peakRevenueIndex)
+              const y = yScale.getPixelForValue(revenue[peakRevenueIndex])
+
+              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[peakRevenueIndex]).toLocaleString()}`, x + 10, y + 2)
+            }
+
+            if (peakQtyIndex >= 0 && peakQtyIndex < qty.length && qty[peakQtyIndex] != null) {
+              const x = xScale.getPixelForValue(peakQtyIndex)
+              const y = yScale.getPixelForValue(qty[peakQtyIndex])
+
+              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[peakQtyIndex]).toLocaleString()}件`, x + 10, y + 2)
+            }
+            ctx.restore()
+          }
+        }]
+      })
+    },
+    renderStageCompare() {
+      const canvas = this.$refs.stageCompareRef
+      if (!canvas || !this.detail) {
+        if (this.stageCompareChart) {
+          this.stageCompareChart.destroy()
+          this.stageCompareChart = null
+        }
+        return
+      }
+
+      const stats = this.displayStageStats || {}
+      const labels = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+      const revenueData = labels.map(stage => (stats[stage] && stats[stage].totalRevenue) || 0)
+      const qtyData = labels.map(stage => (stats[stage] && stats[stage].totalQuantity) || 0)
+      const durationData = labels.map(stage => (stats[stage] && stats[stage].durationDays) || 0)
+
+      if (this.stageCompareChart) this.stageCompareChart.destroy()
+      this.stageCompareChart = new Chart(canvas, {
+        type: 'bar',
+        data: {
+          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 target = date instanceof Date ? date : new Date(date)
+      if (Number.isNaN(target.getTime())) return ''
+      const year = target.getFullYear()
+      const month = String(target.getMonth() + 1).padStart(2, '0')
+      const day = String(target.getDate()).padStart(2, '0')
+      return `${year}-${month}-${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 link = document.createElement('a')
+      link.href = url
+      link.download = `${this.currentConfig.exportPrefix}_${this.formatUploadDate(new Date())}.json`
+      document.body.appendChild(link)
+      link.click()
+      document.body.removeChild(link)
+      URL.revokeObjectURL(url)
+    },
+    pickFirstValue(results) {
+      const keys = Object.keys(results || {})
+      return keys.find(key => key !== '_analysis_summary_') || ''
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.app-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  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;
+  }
+}
+
+.analysis-switch {
+  display: inline-flex;
+  padding: 4px;
+  border-radius: 10px;
+  background: #f3f6fb;
+  border: 1px solid #dce6f2;
+}
+
+.switch-btn {
+  border: none;
+  background: transparent;
+  color: #606266;
+  padding: 8px 16px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.switch-btn.active {
+  background: #ffffff;
+  color: #2563eb;
+  box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
+}
+
+.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;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  min-height: 280px;
+  background: #ffffff;
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  color: #909399;
+  font-size: 14px;
+
+  i {
+    font-size: 40px;
+    color: #c0c4cc;
+  }
+
+  p {
+    margin: 0;
+  }
+}
+
+.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-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-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-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-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); }
+
+.entity-select {
+  min-width: 220px;
+  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 (max-width: 767px) {
+  .page-header,
+  .upload-toolbar {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .analysis-switch,
+  .toolbar-left {
+    width: 100%;
+  }
+
+  .switch-btn {
+    flex: 1;
+  }
+}
+
+@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>

Diferenças do arquivo suprimidas por serem muito extensas
+ 237 - 708
src/views/lifecycle/overview/index.vue


+ 989 - 0
src/views/lifecycle/overview/index_python.vue

@@ -0,0 +1,989 @@
+<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>
+
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="upload"
+          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>
+
+    <!-- 关键指标卡片 -->
+    <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 { analyzeFile, getResults } from '@/api/lifecycle'
+import { getToken } from '@/utils/auth'
+import * as echarts from 'echarts'
+require('echarts/theme/macarons')
+
+const CACHE_KEY_RESULTS = 'analysis_results'
+
+function loadCachedResults() {
+  try {
+    const rawResults = localStorage.getItem(CACHE_KEY_RESULTS)
+    return rawResults ? JSON.parse(rawResults) : {}
+  } catch (e) {
+    return {}
+  }
+}
+
+export default {
+  name: 'LifecycleOverview',
+  data() {
+    return {
+      // 图表实例
+      stageDistributionChart: null,
+      stageMetricsChart: null,
+      avgDurationChart: null,
+      funnelChart: null,
+      // 数据
+      results: loadCachedResults(),
+      // 计算属性数据
+      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_PYTHON_API + '/api/sku-lifecycle/upload',
+        // 文件名称
+        fileName: '',
+        // 已选择文件名称
+        pendingFileName: '',
+        // 是否忽略文件选择改变
+        ignoreFileChange: false,
+      }
+    }
+  },
+  computed: {
+    hasResults() {
+      return Object.keys(this.results || {}).length > 0
+    }
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initCharts()
+      if (this.hasResults) {
+        this.calculateMetrics()
+        this.renderCharts()
+      } else {
+        this.getList()
+      }
+    })
+    // 监听窗口大小变化
+    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: {
+    /** 文件选择改变处理 */
+    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(event, file, fileList) {
+      this.upload.isUploading = true
+    },
+    /** 自定义上传方法 */
+    customUpload(options) {
+      const file = options.file
+      this.upload.isUploading = true
+      analyzeFile(file).then(response => {
+        this.upload.isUploading = false
+        if (response && response.success) {
+          this.$modal.msgSuccess('文件上传并分析成功')
+          this.upload.fileName = this.upload.pendingFileName || file.name
+          this.upload.pendingFileName = ''
+          this.setResults(response.data || {})
+          options.onSuccess(response)
+        } else {
+          const message = (response && response.message) || '分析失败'
+          this.$modal.msgError(message)
+          options.onError(new Error(message))
+        }
+      }).catch(error => {
+        this.upload.isUploading = false
+        const errorMsg = (error && error.response && error.response.data && error.response.data.message) || error.message || '文件上传失败,请重试'
+        this.$modal.msgError(errorMsg)
+        options.onError(error)
+      }).finally(() => {
+        this.resetUploaderSelection()
+      })
+    },
+    /** 提交上传文件 */
+    submitUpload() {
+      const fileList = this.$refs.upload.uploadFiles
+      if (!fileList || fileList.length === 0) {
+        this.$modal.msgError('请选择要上传的文件')
+        return
+      }
+      this.$refs.upload.submit()
+    },
+    /** 重置上传 */
+    resetUpload() {
+      if (this.$refs.upload) this.$refs.upload.clearFiles()
+      if (this.$refs.toolbarUpload) this.$refs.toolbarUpload.clearFiles()
+    },
+    resetUploaderSelection() {
+      if (!this.$refs.upload) return
+      this.upload.ignoreFileChange = true
+      this.$refs.upload.clearFiles()
+      this.$nextTick(() => {
+        this.upload.ignoreFileChange = false
+      })
+    },
+    persistResults() {
+      try {
+        localStorage.setItem(CACHE_KEY_RESULTS, JSON.stringify(this.results || {}))
+      } catch (e) {}
+    },
+    setResults(results) {
+      this.results = results || {}
+      this.persistResults()
+      this.calculateMetrics()
+      this.$nextTick(() => {
+        this.renderCharts()
+      })
+    },
+    /** 获取生命周期分析结果 */
+    getList() {
+      getResults().then(response => {
+        if (response && response.success && response.data) {
+          this.setResults(response.data || {})
+        }
+      }).catch(() => {
+        if (!this.hasResults) {
+          this.setResults({})
+        }
+      })
+    },
+    /** 计算关键指标 */
+    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 }
+    },
+    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 = `sku_lifecycle_results_${this.formatUploadDate(new Date())}.json`
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+      URL.revokeObjectURL(url)
+    }
+  }
+}
+</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;
+}
+
+.upload-card {
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.upload-card .el-card__header {
+  padding: 12px 16px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.upload-row {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.upload-compact {
+  flex: 1;
+  max-width: 260px;
+}
+
+.upload-compact ::v-deep .el-upload-dragger {
+  width: 100%;
+  height: 140px;
+  padding: 10px 12px;
+  border-radius: 8px;
+}
+
+.upload-compact ::v-deep .el-icon-upload {
+  font-size: 26px;
+  margin-bottom: 4px;
+}
+
+.upload-compact ::v-deep .el-upload__text {
+  font-size: 13px;
+}
+
+.upload-compact ::v-deep .el-upload__tip {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.upload-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: 120px;
+}
+
+::v-deep .el-card {
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+::v-deep .el-card__header {
+  border-bottom: 1px solid #eef2f7;
+}
+
+.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>
+

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff