Parcourir la source

生命周期模块各部分整合

Zhu Jiaqi il y a 3 mois
Parent
commit
bb145406de

+ 3 - 0
.env.development

@@ -7,5 +7,8 @@ ENV = 'development'
 # 若依管理系统/开发环境
 VUE_APP_BASE_API = '/dev-api'
 
+# python接口
+VUE_APP_PYTHON_API = '/py-api'
+
 # 路由懒加载
 VUE_CLI_BABEL_TRANSPILE_MODULES = true

+ 2 - 0
package.json

@@ -25,11 +25,13 @@
   },
   "dependencies": {
     "@riophae/vue-treeselect": "0.4.0",
+    "@vue/composition-api": "^1.7.2",
     "axios": "0.28.1",
     "chart.js": "^2.9.4",
     "clipboard": "2.0.8",
     "core-js": "3.37.1",
     "echarts": "5.4.0",
+    "element-plus": "^2.13.1",
     "element-ui": "2.15.14",
     "file-saver": "2.0.5",
     "fuse.js": "6.4.3",

+ 70 - 14
src/api/client.js

@@ -1,7 +1,7 @@
 import axios from 'axios'
 
 const http = axios.create({
-  baseURL: '/api',
+  baseURL: process.env.VUE_APP_PYTHON_API,
   timeout: 300000, // 5分钟超时,用于大数据量分析
 })
 
@@ -9,7 +9,9 @@ export async function uploadFile(file) {
   const form = new FormData()
   form.append('file', file)
   // 不要手动设置 Content-Type,让浏览器自动添加 boundary
-  const { data } = await http.post('/sku-lifecycle/upload', form)
+  const { data } = await http.post('/api/sku-lifecycle/upload', form, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
   return data
 }
 
@@ -21,7 +23,7 @@ export async function analyzeFile(file) {
   }
 
   // Analyze cached data
-  const { data } = await http.post('/sku-lifecycle/analyze')
+  const { data } = await http.post('/api/sku-lifecycle/analyze')
   return data
 }
 
@@ -36,31 +38,61 @@ export async function analyzeFile(file) {
 // }
 
 export async function getResults() {
-  const { data } = await http.get('/sku-lifecycle/results')
+  const { data } = await http.get('/api/sku-lifecycle/results')
   return data
 }
 
 export async function getResultBySku(sku) {
-  const { data } = await http.get(`/sku-lifecycle/results/${encodeURIComponent(sku)}`)
+  const { data } = await http.get(`/api/sku-lifecycle/results/${encodeURIComponent(sku)}`)
+  return data
+}
+
+// SPU 生命周期相关 API
+export async function uploadSpuFile(file) {
+  const form = new FormData()
+  form.append('file', file)
+  const { data } = await http.post('/api/spu-lifecycle/upload', form, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+  return data
+}
+
+export async function analyzeSpuFile(file) {
+  const uploadResult = await uploadSpuFile(file)
+  if (!uploadResult.success) {
+    throw new Error(uploadResult.message || 'File upload failed')
+  }
+
+  const { data } = await http.post('/api/spu-lifecycle/analyze')
+  return data
+}
+
+export async function getSpuResults() {
+  const { data } = await http.get('/api/spu-lifecycle/results')
+  return data
+}
+
+export async function getResultBySpu(spu) {
+  const { data } = await http.get(`/api/spu-lifecycle/results/${encodeURIComponent(spu)}`)
   return data
 }
 
 export async function getCacheInfo() {
-  const { data } = await http.get('/sku-lifecycle/cache/info')
+  const { data } = await http.get('/api/sku-lifecycle/cache/info')
   return data
 }
 
 export async function clearBackendCache() {
-  const { data } = await http.delete('/sku-lifecycle/cache')
+  const { data } = await http.delete('/api/sku-lifecycle/cache')
 }
 
 export async function simulateStrategy(payload) {
-  const { data } = await http.post('/sku-lifecycle/strategy-simulation', payload)
+  const { data } = await http.post('/api/sku-lifecycle/strategy-simulation', payload)
   return data
 }
 
 export async function getInsights(payload) {
-  const { data } = await http.post('/sku-lifecycle/insights', payload)
+  const { data } = await http.post('/api/sku-lifecycle/insights', payload)
   return data
 }
 
@@ -68,12 +100,14 @@ export async function getInsights(payload) {
 export async function uploadHotProductFile(file) {
   const form = new FormData()
   form.append('file', file)
-  const { data } = await http.post('/sku-lifecycle/upload', form)
+  const { data } = await http.post('/api/sku-lifecycle/upload', form, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
   return data
 }
 
 export async function analyzeHotProduct() {
-  const { data } = await http.post('/hotproduct/analyze')
+  const { data } = await http.post('/api/hotproduct/analyze')
   return data
 }
 
@@ -88,14 +122,36 @@ export async function analyzeHotProductWithFile(file) {
   return await analyzeHotProduct()
 }
 
+// SPU 爆款分析相关 API
+export async function analyzeSpuHotProductWithFile(file) {
+  // Upload file first
+  const uploadResult = await uploadSpuFile(file)
+  if (!uploadResult.success) {
+    throw new Error(uploadResult.message || 'File upload failed')
+  }
+
+  const { data } = await http.post('/api/spu-hotproduct/analyze')
+  return data
+}
+
+export async function getSpuHotProductResults() {
+  const { data } = await http.get('/api/spu-hotproduct/results')
+  return data
+}
+
+export async function getSpuHotProductResultBySpu(spu) {
+  const { data } = await http.get(`/api/spu-hotproduct/results/${encodeURIComponent(spu)}`)
+  return data
+}
+
 
 
 export async function getHotProductResults() {
-  const { data } = await http.get('/hotproduct/results')
+  const { data } = await http.get('/api/hotproduct/results')
   return data
 }
 
 export async function getHotProductResultBySku(sku) {
-  const { data } = await http.get(`/hotproduct/results/${encodeURIComponent(sku)}`)
+  const { data } = await http.get(`/api/hotproduct/results/${encodeURIComponent(sku)}`)
   return data
-}
+}

+ 2 - 2
src/api/lifecycle.js

@@ -5,7 +5,7 @@ export function uploadAndAnalyze(file) {
   const formData = new FormData()
   formData.append('file', file)
   return request({
-    url: '/statistics/lifecycle/upload',
+    url: '/lifecycle/upload',
     method: 'post',
     data: formData,
     headers: {
@@ -18,7 +18,7 @@ export function uploadAndAnalyze(file) {
 // 获取生命周期分析结果
 export function getLifecycleResults() {
   return request({
-    url: '/statistics/lifecycle/results',
+    url: '/lifecycle/results',
     method: 'get'
   })
 }

+ 3 - 1
src/store/index.js

@@ -7,6 +7,7 @@ import tagsView from './modules/tagsView'
 import permission from './modules/permission'
 import settings from './modules/settings'
 import analysis from './analysis'
+import spuAnalysis from './spuAnalysis'
 import getters from './getters'
 
 Vue.use(Vuex)
@@ -19,7 +20,8 @@ const store = new Vuex.Store({
     tagsView,
     permission,
     settings,
-    analysis
+    analysis,
+    spuAnalysis
   },
   getters
 })

+ 142 - 0
src/store/spuAnalysis.js

@@ -0,0 +1,142 @@
+import * as api from '../api/client'
+
+const LS_KEY_RESULTS = 'spu_analysis_results'
+const LS_KEY_SELECTED_SPU = 'spu_analysis_selectedSpu'
+
+function loadFromLocalStorage() {
+  try {
+    const raw = localStorage.getItem(LS_KEY_RESULTS)
+    const res = raw ? JSON.parse(raw) : {}
+    const sel = localStorage.getItem(LS_KEY_SELECTED_SPU) || ''
+    return { res, sel }
+  } catch (e) {
+    return { res: {}, sel: '' }
+  }
+}
+
+function saveToLocalStorage(results, selectedSpu) {
+  try {
+    localStorage.setItem(LS_KEY_RESULTS, JSON.stringify(results || {}))
+    localStorage.setItem(LS_KEY_SELECTED_SPU, selectedSpu || '')
+  } catch (e) {}
+}
+
+function pickFirstSpu(obj) {
+  const keys = Object.keys(obj || {})
+  const first = keys.find(k => k !== '_analysis_summary_')
+  return first || ''
+}
+
+const state = () => {
+  const cached = loadFromLocalStorage()
+  return {
+    results: cached.res,
+    loading: false,
+    error: null,
+    selectedSpu: cached.sel
+  }
+}
+
+const mutations = {
+  SET_RESULTS(state, payload) {
+    state.results = payload || {}
+  },
+  SET_LOADING(state, payload) {
+    state.loading = !!payload
+  },
+  SET_ERROR(state, payload) {
+    state.error = payload || null
+  },
+  SET_SELECTED_SPU(state, payload) {
+    state.selectedSpu = payload || ''
+  }
+}
+
+const actions = {
+  async uploadFile({ commit }, file) {
+    commit('SET_LOADING', true)
+    commit('SET_ERROR', null)
+    try {
+      const res = await api.uploadSpuFile(file)
+      return res
+    } catch (e) {
+      commit('SET_ERROR', (e && e.message) || '上传失败')
+      throw e
+    } finally {
+      commit('SET_LOADING', false)
+    }
+  },
+  async analyzeFile({ commit }, file) {
+    commit('SET_LOADING', true)
+    commit('SET_ERROR', null)
+    try {
+      const response = await api.analyzeSpuFile(file)
+      const results = response && response.data ? response.data : {}
+      commit('SET_RESULTS', results)
+      const firstSpu = pickFirstSpu(results)
+      commit('SET_SELECTED_SPU', firstSpu || '')
+      saveToLocalStorage(results, firstSpu || '')
+    } catch (e) {
+      if (e && (e.code === 'ECONNABORTED' || (e.message && e.message.indexOf('timeout') >= 0))) {
+        commit('SET_ERROR', '分析超时,数据量较大,请稍后重试或联系管理员')
+      } else {
+        const msg = (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || '分析失败'
+        commit('SET_ERROR', msg)
+      }
+      throw e
+    } finally {
+      commit('SET_LOADING', false)
+    }
+  },
+  async analyzeProductLifecycle({ commit }, file) {
+    commit('SET_LOADING', true)
+    commit('SET_ERROR', null)
+    try {
+      const response = await api.analyzeProductLifecycle(file)
+      return response
+    } catch (e) {
+      commit('SET_ERROR', (e && e.message) || '商品生命周期分析失败')
+      throw e
+    } finally {
+      commit('SET_LOADING', false)
+    }
+  },
+  async fetchResults({ commit, state }) {
+    commit('SET_LOADING', true)
+    commit('SET_ERROR', null)
+    try {
+      const results = await api.getSpuResults()
+      if (results && !results.message) {
+        commit('SET_RESULTS', results)
+        let selectedSpu = state.selectedSpu
+        if (!selectedSpu || !results[selectedSpu]) {
+          selectedSpu = pickFirstSpu(results)
+          commit('SET_SELECTED_SPU', selectedSpu)
+        }
+        saveToLocalStorage(results, selectedSpu)
+      }
+    } catch (e) {
+      commit('SET_ERROR', (e && e.message) || '获取结果失败')
+    } finally {
+      commit('SET_LOADING', false)
+    }
+  },
+  selectSpu({ commit, state }, spu) {
+    const next = spu || ''
+    commit('SET_SELECTED_SPU', next)
+    saveToLocalStorage(state.results, next)
+  },
+  clearResults({ commit }) {
+    commit('SET_RESULTS', {})
+    commit('SET_SELECTED_SPU', '')
+    commit('SET_ERROR', null)
+    saveToLocalStorage({}, '')
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 12 - 11
src/views/lifecycle/overview/index.vue

@@ -165,7 +165,7 @@
 </template>
 
 <script>
-import { uploadAndAnalyze, getLifecycleResults } from '@/api/lifecycle'
+import { analyzeFile, getResults } from '@/api/client'
 import { getToken } from '@/utils/auth'
 import * as echarts from 'echarts'
 require('echarts/theme/macarons')
@@ -202,7 +202,7 @@ export default {
         // 设置上传的请求头部
         headers: { Authorization: 'Bearer ' + getToken() },
         // 上传的地址
-        url: process.env.VUE_APP_BASE_API + '/statistics/lifecycle/upload',
+        url: process.env.VUE_APP_PYTHON_API + '/api/sku-lifecycle/upload',
         // 文件名称
         fileName: '',
         // 已选择文件名称
@@ -258,14 +258,14 @@ export default {
                       file.name.endsWith('.xlsx') ||
                       file.name.endsWith('.xls') ||
                       file.name.endsWith('.csv')
-      const isLt20M = file.size / 1024 / 1024 < 20
+      const isLt500M = file.size / 1024 / 1024 < 500
 
       if (!isExcel) {
         this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
         return false
       }
-      if (!isLt20M) {
-        this.$modal.msgError('上传文件大小不能超过 20MB!')
+      if (!isLt500M) {
+        this.$modal.msgError('上传文件大小不能超过 500MB!')
         return false
       }
       return true
@@ -278,9 +278,9 @@ export default {
     customUpload(options) {
       const file = options.file
       this.upload.isUploading = true
-      uploadAndAnalyze(file).then(response => {
+      analyzeFile(file).then(response => {
         this.upload.isUploading = false
-        if (response.code === 200) {
+        if (response && response.success) {
           this.$modal.msgSuccess('文件上传并分析成功')
           this.upload.fileName = this.upload.pendingFileName || file.name
           this.upload.pendingFileName = ''
@@ -291,8 +291,9 @@ export default {
           })
           options.onSuccess(response)
         } else {
-          this.$modal.msgError(response.msg || '分析失败')
-          options.onError(new Error(response.msg || '分析失败'))
+          const message = (response && response.message) || '分析失败'
+          this.$modal.msgError(message)
+          options.onError(new Error(message))
         }
         if (this.$refs.upload) {
           this.upload.ignoreFileChange = true
@@ -324,8 +325,8 @@ export default {
     },
     /** 获取生命周期分析结果 */
     getList() {
-      getLifecycleResults().then(response => {
-        if (response.code === 200 && response.data) {
+      getResults().then(response => {
+        if (response && response.success && response.data) {
           this.results = response.data || {}
           this.calculateMetrics()
           this.$nextTick(() => {

+ 0 - 0
src/views/lifecycle/simulation/index.vue


+ 5 - 4
src/views/lifecycle/skuAnalysis/index.vue

@@ -221,6 +221,7 @@
 
 <script setup>
 import { uploadAndAnalyze, getLifecycleResults } from '@/api/lifecycle'
+import { analyzeFile, getResults } from '@/api/client'
 import { getToken } from '@/utils/auth'
 import { Chart } from 'chart.js'
 import { formatCurrency, formatDate } from '../../../utils/format'
@@ -245,7 +246,7 @@ export default {
         // 设置上传的请求头部
         headers: { Authorization: 'Bearer ' + getToken() },
         // 上传的地址
-        url: process.env.VUE_APP_BASE_API + '/statistics/lifecycle/upload',
+        url: process.env.VUE_APP_PYTHON_API + '/statistics/lifecycle/upload',
         // 文件名称
         fileName: '',
         // 已选择文件名称
@@ -425,7 +426,7 @@ export default {
   methods: {
     /** 获取生命周期分析结果 */
     getList() {
-      getLifecycleResults().then(response => {
+      getResults().then(response => {
         if (response && response.code === 200 && response.data) {
           const results = response.data || {}
           this.$store.commit('analysis/SET_RESULTS', results)
@@ -475,9 +476,9 @@ export default {
     customUpload(options) {
       const file = options.file
       this.upload.isUploading = true
-      uploadAndAnalyze(file).then(response => {
+      analyzeFile(file).then(response => {
         this.upload.isUploading = false
-        if (response && response.code === 200) {
+        if (response && response.success) {
           const results = response.data || {}
           this.$store.commit('analysis/SET_RESULTS', results)
           const firstSku = this.pickFirstSku(results)

+ 1406 - 0
src/views/lifecycle/skuPred/index.vue

@@ -0,0 +1,1406 @@
+<template>
+  <div class="app-container">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2><i class="el-icon-s-data"></i> SKU爆品分析</h2>
+      <p class="page-desc">基于五维指标体系的爆款系数分析,快速识别超级爆款、潜力爆款、常规款和清货款</p>
+    </div>
+
+    <!-- 上传工具栏 -->
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="toolbarUpload"
+          class="toolbar-upload"
+          :limit="1"
+          accept=".xlsx,.xls,.csv"
+          :http-request="customUpload"
+          :disabled="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="fileName">已上传:{{ fileName }}</div>
+      <div class="toolbar-status" v-else-if="pendingFileName">已选择:{{ 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">总SKU数</p>
+            <p class="stat-value">{{ summary.total_sku_count || 0 }}</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">按SKU数量统计</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>
+
+    <!-- SKU详情表格 -->
+    <div class="table-card mb-20">
+      <div class="table-header">
+        <h3 class="table-title">SKU详情列表</h3>
+        <div class="table-filters">
+          <el-select v-model="filterLevel" placeholder="全部等级" clearable size="small" class="filter-select">
+            <el-option value="超级爆款" label="超级爆款"></el-option>
+            <el-option value="潜力爆款" label="潜力爆款"></el-option>
+            <el-option value="常规款" label="常规款"></el-option>
+            <el-option value="清货款" label="清货款"></el-option>
+          </el-select>
+          <el-input 
+            v-model="searchKeyword" 
+            placeholder="搜索SKU或商品名称..." 
+            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>SKU 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 filteredAndSortedSkus" :key="item.sku_id" 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: ((item.hotproduct_score || 0) * 100) + '%' }"
+                    ></div>
+                  </div>
+                </div>
+              </td>
+              <td>
+                <span class="level-badge" :class="getLevelBadgeClass(item.hotproduct_level)">
+                  {{ item.hotproduct_level || '-' }}
+                </span>
+              </td>
+              <td class="text-sm">{{ item.sku_id || '-' }}</td>
+              <td class="text-sm product-title" :title="item.product_title">
+                {{ item.product_title || '-' }}
+              </td>
+              <td class="text-sm">{{ (item.raw_data && item.raw_data.total_quantity) || '-' }}</td>
+              <td class="text-sm">{{ formatCurrency((item.raw_data && item.raw_data.total_revenue) || 0) }}</td>
+              <td>
+                <el-button 
+                  type="text" 
+                  size="small"
+                  @click="showDetail(item)"
+                  class="action-btn"
+                >
+                  查看详情
+                </el-button>
+              </td>
+            </tr>
+            <tr v-if="filteredAndSortedSkus.length === 0">
+              <td colspan="7" class="empty-state">
+                <i class="el-icon-inbox"></i>
+                <p>未找到符合条件的SKU</p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- SKU详情弹窗 -->
+    <el-dialog
+      :visible.sync="detailVisible"
+      :title="selectedSku ? selectedSku.product_title : ''"
+      width="80%"
+      top="5vh"
+      custom-class="detail-dialog"
+      :close-on-click-modal="false"
+    >
+      <div v-if="selectedSku" class="detail-content">
+        <!-- SKU基本信息 -->
+        <div class="detail-info mb-6">
+          <p class="info-label">SKU ID: <span class="info-value">{{ selectedSku.sku_id }}</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">{{ selectedSku.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(selectedSku.hotproduct_level)">
+              {{ selectedSku.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 class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">销售热度(单位时间销量)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSku, 'sales_heat') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-blue" :style="{ width: (getMetricValue(selectedSku, 'sales_heat') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 40%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">价格接受度(实收率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSku, 'price_acceptance') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-green" :style="{ width: (getMetricValue(selectedSku, 'price_acceptance') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 30%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">退款稳定性(1-退款率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSku, 'refund_stability') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-yellow" :style="{ width: (getMetricValue(selectedSku, 'refund_stability') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">复购热度(复购率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSku, 'repurchase_rate') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-purple" :style="{ width: (getMetricValue(selectedSku, 'repurchase_rate') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">夜间爆发力(0-6点占比)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSku, 'night_burst') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-indigo" :style="{ width: (getMetricValue(selectedSku, 'night_burst') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</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(selectedSku, 'total_quantity') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">总销售额</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedSku, 'total_revenue')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">平均价格</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedSku, 'avg_price')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">天数跨度</p>
+              <p class="raw-data-value">{{ getRawData(selectedSku, 'days_span') }}天</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">日均销量</p>
+              <p class="raw-data-value">{{ getRawData(selectedSku, 'daily_sales') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">独立买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedSku, 'unique_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">复购买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedSku, 'repurchase_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">退款率</p>
+              <p class="raw-data-value">{{ formatPercent(getRawData(selectedSku, '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(selectedSku.hotproduct_level) }}</p>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { Chart } from 'chart.js'
+import { analyzeHotProductWithFile } from '@/api/client'
+
+export default {
+  name: 'SkuHotProductAnalysis',
+  data() {
+    return {
+      loading: false,
+      error: '',
+      results: null,
+      selectedSku: null,
+      detailVisible: false,
+
+      filterLevel: '',
+      searchKeyword: '',
+      sortColumn: 'hotproduct_score',
+      sortOrder: 'desc',
+
+      fileName: '',
+      pendingFileName: '',
+      ignoreFileChange: false,
+
+      // Chart 实例
+      levelDistChart: null,
+      metricsCompareChart: null,
+      radarChart: null
+    }
+  },
+
+  computed: {
+    hasResults() {
+      return this.results !== null && Object.keys(this.results).length > 0
+    },
+    summary() {
+      return (this.results && this.results.summary) || {}
+    },
+    levelDistribution() {
+      return (this.results && this.results.summary && this.results.summary.level_distribution) || {}
+    },
+    skuList() {
+      if (!this.results || !this.results.sku_results) return []
+      return Object.values(this.results.sku_results)
+    },
+    filteredAndSortedSkus() {
+      let list = this.skuList.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 skuId = String(item.sku_id || '').toLowerCase()
+          const title = String(item.product_title || '').toLowerCase()
+          return skuId.includes(keyword) || title.includes(keyword)
+        })
+      }
+
+      // 排序
+      const col = this.sortColumn
+      const order = this.sortOrder
+      list.sort((a, b) => {
+        let aVal = 0
+        let bVal = 0
+
+        if (col === 'hotproduct_score') {
+          aVal = Number(a.hotproduct_score) || 0
+          bVal = Number(b.hotproduct_score) || 0
+        } else if (col === 'total_quantity') {
+          aVal = Number(a.raw_data && a.raw_data.total_quantity) || 0
+          bVal = Number(b.raw_data && b.raw_data.total_quantity) || 0
+        } else if (col === 'total_revenue') {
+          aVal = Number(a.raw_data && a.raw_data.total_revenue) || 0
+          bVal = Number(b.raw_data && b.raw_data.total_revenue) || 0
+        }
+
+        return order === 'asc' ? (aVal - bVal) : (bVal - aVal)
+      })
+
+      return list
+    }
+  },
+
+  watch: {
+    results() {
+      this.$nextTick(() => {
+        this.renderCharts()
+      })
+    },
+    detailVisible(val) {
+      if (!val && this.radarChart) {
+        this.radarChart.destroy()
+        this.radarChart = null
+      } else if (val && this.selectedSku) {
+        this.$nextTick(() => {
+          this.renderRadarChart()
+        })
+      }
+    }
+  },
+
+  mounted() {
+    this.renderCharts()
+  },
+
+  beforeDestroy() {
+    if (this.levelDistChart) this.levelDistChart.destroy()
+    if (this.metricsCompareChart) this.metricsCompareChart.destroy()
+    if (this.radarChart) this.radarChart.destroy()
+  },
+
+  methods: {
+    handleFileChange(file, fileList) {
+      if (this.ignoreFileChange) return
+      if (!fileList || fileList.length === 0) return
+      if (!file || !file.raw) return
+
+      this.pendingFileName = file.name
+      this.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
+    },
+
+    customUpload(options) {
+      const file = options.file
+      this.loading = true
+      this.error = ''
+
+      analyzeHotProductWithFile(file).then(response => {
+        this.loading = false
+        if (response && response.success) {
+          this.results = response.data || {}
+          this.$message.success('文件上传并分析成功')
+          this.fileName = this.pendingFileName || file.name
+          this.pendingFileName = ''
+          this.$nextTick(() => {
+            this.renderCharts()
+          })
+          options.onSuccess(response)
+        } else {
+          const msg = (response && response.message) || '分析失败'
+          this.error = msg
+          this.$message.error(msg)
+          options.onError(new Error(msg))
+        }
+      }).catch(error => {
+        this.loading = false
+        const msg = (error && error.response && error.response.data && error.response.data.message) ||
+          (error && error.message) || '文件上传失败,请重试'
+        this.error = msg
+        this.$message.error(msg)
+        options.onError(error)
+      }).finally(() => {
+        if (this.$refs.toolbarUpload) {
+          this.ignoreFileChange = true
+          this.$refs.toolbarUpload.clearFiles()
+          this.$nextTick(() => {
+            this.ignoreFileChange = false
+          })
+        }
+      })
+    },
+
+    submitUpload() {
+      const target = this.$refs.toolbarUpload
+      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      if (!fileList || fileList.length === 0) {
+        this.$message.error('请选择要上传的文件')
+        return
+      }
+      target.submit()
+    },
+
+    renderCharts() {
+      this.renderLevelDistChart()
+      this.renderMetricsCompareChart()
+    },
+
+    renderLevelDistChart() {
+      if (this.levelDistChart) this.levelDistChart.destroy()
+
+      const canvas = this.$refs.levelDistChartRef
+      if (!canvas) return
+
+      const levels = ['超级爆款', '潜力爆款', '常规款', '清货款']
+      const data = levels.map(level => this.levelDistribution[level] || 0)
+      const colors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+
+      this.levelDistChart = new Chart(canvas, {
+        type: 'doughnut',
+        data: {
+          labels: levels,
+          datasets: [{
+            data,
+            backgroundColor: colors,
+            borderWidth: 2,
+            borderColor: '#fff'
+          }]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          plugins: {
+            legend: {
+              position: 'right',
+              labels: {
+                padding: 15,
+                font: { size: 12 }
+              }
+            },
+            tooltip: {
+              callbacks: {
+                label: (context) => {
+                  const label = context.label || ''
+                  const value = context.parsed || 0
+                  const total = (context.dataset.data || []).reduce((a, b) => a + b, 0)
+                  const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
+                  return `${label}: ${value} (${percentage}%)`
+                }
+              }
+            }
+          }
+        }
+      })
+    },
+
+    renderMetricsCompareChart() {
+      if (this.metricsCompareChart) this.metricsCompareChart.destroy()
+
+      const canvas = this.$refs.metricsCompareChartRef
+      if (!canvas) return
+
+      const levels = ['超级爆款', '潜力爆款', '常规款', '清货款']
+      const metricNames = ['销售热度', '价格接受度', '退款稳定性', '复购热度', '夜间爆发力']
+      const metricKeys = ['sales_heat', 'price_acceptance', 'refund_stability', 'repurchase_rate', 'night_burst']
+
+      // 计算各等级平均指标
+      const levelMetrics = {}
+      levels.forEach(level => {
+        const skus = this.skuList.filter(s => s.hotproduct_level === level)
+        if (skus.length > 0) {
+          levelMetrics[level] = metricKeys.map(key => {
+            const sum = skus.reduce((acc, sku) => acc + (Number(sku.metrics && sku.metrics[key]) || 0), 0)
+            return sum / skus.length
+          })
+        } else {
+          levelMetrics[level] = metricKeys.map(() => 0)
+        }
+      })
+
+      const baseColors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+      const datasets = levels.map((level, idx) => ({
+        label: level,
+        data: levelMetrics[level] || [],
+        backgroundColor: baseColors[idx] + '33',
+        borderColor: baseColors[idx],
+        borderWidth: 2
+      }))
+
+      this.metricsCompareChart = new Chart(canvas, {
+        type: 'radar',
+        data: {
+          labels: metricNames,
+          datasets
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          scales: {
+            r: {
+              beginAtZero: true,
+              max: 1,
+              ticks: { stepSize: 0.2 }
+            }
+          },
+          plugins: {
+            legend: { position: 'bottom' }
+          }
+        }
+      })
+    },
+
+    renderRadarChart() {
+      if (!this.selectedSku) return
+      if (this.radarChart) this.radarChart.destroy()
+
+      const canvas = this.$refs.radarChartRef
+      if (!canvas) return
+
+      const metricNames = ['销售热度', '价格接受度', '退款稳定性', '复购热度', '夜间爆发力']
+      const metricKeys = ['sales_heat', 'price_acceptance', 'refund_stability', 'repurchase_rate', 'night_burst']
+      const data = metricKeys.map(key => Number(this.selectedSku.metrics && this.selectedSku.metrics[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,
+          scales: {
+            r: {
+              beginAtZero: true,
+              max: 1,
+              ticks: { stepSize: 0.2 }
+            }
+          },
+          plugins: {
+            legend: { display: false }
+          }
+        }
+      })
+    },
+
+    showDetail(sku) {
+      this.selectedSku = sku
+      this.detailVisible = true
+    },
+
+    closeDetail() {
+      this.detailVisible = false
+      this.selectedSku = 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'
+    },
+
+    getScoreColorClass(score) {
+      const s = Number(score) || 0
+      if (s >= 0.8) return 'score-red'
+      if (s >= 0.6) return 'score-yellow'
+      if (s >= 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(sku, key) {
+      if (!sku || !sku.metrics) return '-'
+      const val = sku.metrics[key]
+      return val != null ? String(val) : '-'
+    },
+
+    getRawData(sku, key) {
+      if (!sku || !sku.raw_data) return '-'
+      const val = sku.raw_data[key]
+      return val != null ? val : '-'
+    },
+
+    formatCurrency(val) {
+      if (val == null || val === '-') return '-'
+      const num = Number(val)
+      if (Number.isNaN(num)) return '-'
+      return '¥' + num.toFixed(2)
+    },
+
+    formatPercent(val) {
+      if (val == null || val === '-') return '-'
+      const num = Number(val)
+      if (Number.isNaN(num)) return '-'
+      return num.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 a = document.createElement('a')
+      a.href = url
+      a.download = `sku_hotproduct_results_${new Date().toISOString().slice(0, 10)}.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; }
+.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;
+}
+
+.metric-name {
+  font-size: 13px;
+  color: #606266;
+}
+
+.metric-score {
+  font-size: 16px;
+  font-weight: 700;
+  color: #303133;
+}
+
+.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;
+}
+</style>

+ 1120 - 0
src/views/lifecycle/spuAnalysis/index.vue

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

+ 1406 - 0
src/views/lifecycle/spuPred/index.vue

@@ -0,0 +1,1406 @@
+<template>
+  <div class="app-container">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2><i class="el-icon-s-data"></i> SPU爆品分析</h2>
+      <p class="page-desc">基于五维指标体系的爆款系数分析,快速识别超级爆款、潜力爆款、常规款和清货款</p>
+    </div>
+
+    <!-- 上传工具栏 -->
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="toolbarUpload"
+          class="toolbar-upload"
+          :limit="1"
+          accept=".xlsx,.xls,.csv"
+          :http-request="customUpload"
+          :disabled="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="fileName">已上传:{{ fileName }}</div>
+      <div class="toolbar-status" v-else-if="pendingFileName">已选择:{{ 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">总SPU数</p>
+            <p class="stat-value">{{ summary.total_spu_count || 0 }}</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">按SPU数量统计</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>
+
+    <!-- SPU详情表格 -->
+    <div class="table-card mb-20">
+      <div class="table-header">
+        <h3 class="table-title">SPU详情列表</h3>
+        <div class="table-filters">
+          <el-select v-model="filterLevel" placeholder="全部等级" clearable size="small" class="filter-select">
+            <el-option value="超级爆款" label="超级爆款"></el-option>
+            <el-option value="潜力爆款" label="潜力爆款"></el-option>
+            <el-option value="常规款" label="常规款"></el-option>
+            <el-option value="清货款" label="清货款"></el-option>
+          </el-select>
+          <el-input 
+            v-model="searchKeyword" 
+            placeholder="搜索SPU或商品名称..." 
+            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>SPU??</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 filteredAndSortedSpus" :key="item.spu_id" 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: ((item.hotproduct_score || 0) * 100) + '%' }"
+                    ></div>
+                  </div>
+                </div>
+              </td>
+              <td>
+                <span class="level-badge" :class="getLevelBadgeClass(item.hotproduct_level)">
+                  {{ item.hotproduct_level || '-' }}
+                </span>
+              </td>
+              <td class="text-sm">{{ item.spu_id || '-' }}</td>
+              <td class="text-sm product-title" :title="item.product_title">
+                {{ item.product_title || '-' }}
+              </td>
+              <td class="text-sm">{{ (item.raw_data && item.raw_data.total_quantity) || '-' }}</td>
+              <td class="text-sm">{{ formatCurrency((item.raw_data && item.raw_data.total_revenue) || 0) }}</td>
+              <td>
+                <el-button 
+                  type="text" 
+                  size="small"
+                  @click="showDetail(item)"
+                  class="action-btn"
+                >
+                  查看详情
+                </el-button>
+              </td>
+            </tr>
+            <tr v-if="filteredAndSortedSpus.length === 0">
+              <td colspan="7" class="empty-state">
+                <i class="el-icon-inbox"></i>
+                <p>未找到符合条件的SPU</p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- SPU详情弹窗 -->
+    <el-dialog
+      :visible.sync="detailVisible"
+      :title="selectedSpu ? selectedSpu.product_title : ''"
+      width="80%"
+      top="5vh"
+      custom-class="detail-dialog"
+      :close-on-click-modal="false"
+    >
+      <div v-if="selectedSpu" class="detail-content">
+        <!-- SPU基本信息 -->
+        <div class="detail-info mb-6">
+          <p class="info-label">SPU名称: <span class="info-value">{{ selectedSpu.spu_id }}</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">{{ selectedSpu.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(selectedSpu.hotproduct_level)">
+              {{ selectedSpu.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 class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">销售热度(单位时间销量)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSpu, 'sales_heat') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-blue" :style="{ width: (getMetricValue(selectedSpu, 'sales_heat') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 40%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">价格接受度(实收率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSpu, 'price_acceptance') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-green" :style="{ width: (getMetricValue(selectedSpu, 'price_acceptance') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 30%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">退款稳定性(1-退款率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSpu, 'refund_stability') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-yellow" :style="{ width: (getMetricValue(selectedSpu, 'refund_stability') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">复购热度(复购率)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSpu, 'repurchase_rate') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-purple" :style="{ width: (getMetricValue(selectedSpu, 'repurchase_rate') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</p>
+            </div>
+
+            <div class="metric-item">
+              <div class="metric-header">
+                <span class="metric-name">夜间爆发力(0-6点占比)</span>
+                <span class="metric-score">{{ getMetricValue(selectedSpu, 'night_burst') }}</span>
+              </div>
+              <div class="metric-bar">
+                <div class="metric-progress bg-indigo" :style="{ width: (getMetricValue(selectedSpu, 'night_burst') * 100) + '%' }"></div>
+              </div>
+              <p class="metric-weight">权重: 10%</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(selectedSpu, 'total_quantity') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">总销售额</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedSpu, 'total_revenue')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">平均价格</p>
+              <p class="raw-data-value">{{ formatCurrency(getRawData(selectedSpu, 'avg_price')) }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">天数跨度</p>
+              <p class="raw-data-value">{{ getRawData(selectedSpu, 'days_span') }}天</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">日均销量</p>
+              <p class="raw-data-value">{{ getRawData(selectedSpu, 'daily_sales') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">独立买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedSpu, 'unique_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">复购买家数</p>
+              <p class="raw-data-value">{{ getRawData(selectedSpu, 'repurchase_buyers') }}</p>
+            </div>
+            <div class="raw-data-item">
+              <p class="raw-data-label">退款率</p>
+              <p class="raw-data-value">{{ formatPercent(getRawData(selectedSpu, '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(selectedSpu.hotproduct_level) }}</p>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { Chart } from 'chart.js'
+import { analyzeSpuHotProductWithFile } from '@/api/client'
+
+export default {
+  name: 'SpuHotProductAnalysis',
+  data() {
+    return {
+      loading: false,
+      error: '',
+      results: null,
+      selectedSpu: null,
+      detailVisible: false,
+
+      filterLevel: '',
+      searchKeyword: '',
+      sortColumn: 'hotproduct_score',
+      sortOrder: 'desc',
+
+      fileName: '',
+      pendingFileName: '',
+      ignoreFileChange: false,
+
+      // Chart 实例
+      levelDistChart: null,
+      metricsCompareChart: null,
+      radarChart: null
+    }
+  },
+
+  computed: {
+    hasResults() {
+      return this.results !== null && Object.keys(this.results).length > 0
+    },
+    summary() {
+      return (this.results && this.results.summary) || {}
+    },
+    levelDistribution() {
+      return (this.results && this.results.summary && this.results.summary.level_distribution) || {}
+    },
+    spuList() {
+      if (!this.results || !this.results.spu_results) return []
+      return Object.values(this.results.spu_results)
+    },
+    filteredAndSortedSpus() {
+      let list = this.spuList.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 spuName = String(item.spu_id || '').toLowerCase()
+          const title = String(item.product_title || '').toLowerCase()
+          return spuName.includes(keyword) || title.includes(keyword)
+        })
+      }
+
+      // 排序
+      const col = this.sortColumn
+      const order = this.sortOrder
+      list.sort((a, b) => {
+        let aVal = 0
+        let bVal = 0
+
+        if (col === 'hotproduct_score') {
+          aVal = Number(a.hotproduct_score) || 0
+          bVal = Number(b.hotproduct_score) || 0
+        } else if (col === 'total_quantity') {
+          aVal = Number(a.raw_data && a.raw_data.total_quantity) || 0
+          bVal = Number(b.raw_data && b.raw_data.total_quantity) || 0
+        } else if (col === 'total_revenue') {
+          aVal = Number(a.raw_data && a.raw_data.total_revenue) || 0
+          bVal = Number(b.raw_data && b.raw_data.total_revenue) || 0
+        }
+
+        return order === 'asc' ? (aVal - bVal) : (bVal - aVal)
+      })
+
+      return list
+    }
+  },
+
+  watch: {
+    results() {
+      this.$nextTick(() => {
+        this.renderCharts()
+      })
+    },
+    detailVisible(val) {
+      if (!val && this.radarChart) {
+        this.radarChart.destroy()
+        this.radarChart = null
+      } else if (val && this.selectedSpu) {
+        this.$nextTick(() => {
+          this.renderRadarChart()
+        })
+      }
+    }
+  },
+
+  mounted() {
+    this.renderCharts()
+  },
+
+  beforeDestroy() {
+    if (this.levelDistChart) this.levelDistChart.destroy()
+    if (this.metricsCompareChart) this.metricsCompareChart.destroy()
+    if (this.radarChart) this.radarChart.destroy()
+  },
+
+  methods: {
+    handleFileChange(file, fileList) {
+      if (this.ignoreFileChange) return
+      if (!fileList || fileList.length === 0) return
+      if (!file || !file.raw) return
+
+      this.pendingFileName = file.name
+      this.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
+    },
+
+    customUpload(options) {
+      const file = options.file
+      this.loading = true
+      this.error = ''
+
+      analyzeSpuHotProductWithFile(file).then(response => {
+        this.loading = false
+        if (response && response.success) {
+          this.results = response.data || {}
+          this.$message.success('文件上传并分析成功')
+          this.fileName = this.pendingFileName || file.name
+          this.pendingFileName = ''
+          this.$nextTick(() => {
+            this.renderCharts()
+          })
+          options.onSuccess(response)
+        } else {
+          const msg = (response && response.message) || '分析失败'
+          this.error = msg
+          this.$message.error(msg)
+          options.onError(new Error(msg))
+        }
+      }).catch(error => {
+        this.loading = false
+        const msg = (error && error.response && error.response.data && error.response.data.message) ||
+          (error && error.message) || '文件上传失败,请重试'
+        this.error = msg
+        this.$message.error(msg)
+        options.onError(error)
+      }).finally(() => {
+        if (this.$refs.toolbarUpload) {
+          this.ignoreFileChange = true
+          this.$refs.toolbarUpload.clearFiles()
+          this.$nextTick(() => {
+            this.ignoreFileChange = false
+          })
+        }
+      })
+    },
+
+    submitUpload() {
+      const target = this.$refs.toolbarUpload
+      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      if (!fileList || fileList.length === 0) {
+        this.$message.error('请选择要上传的文件')
+        return
+      }
+      target.submit()
+    },
+
+    renderCharts() {
+      this.renderLevelDistChart()
+      this.renderMetricsCompareChart()
+    },
+
+    renderLevelDistChart() {
+      if (this.levelDistChart) this.levelDistChart.destroy()
+
+      const canvas = this.$refs.levelDistChartRef
+      if (!canvas) return
+
+      const levels = ['超级爆款', '潜力爆款', '常规款', '清货款']
+      const data = levels.map(level => this.levelDistribution[level] || 0)
+      const colors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+
+      this.levelDistChart = new Chart(canvas, {
+        type: 'doughnut',
+        data: {
+          labels: levels,
+          datasets: [{
+            data,
+            backgroundColor: colors,
+            borderWidth: 2,
+            borderColor: '#fff'
+          }]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          plugins: {
+            legend: {
+              position: 'right',
+              labels: {
+                padding: 15,
+                font: { size: 12 }
+              }
+            },
+            tooltip: {
+              callbacks: {
+                label: (context) => {
+                  const label = context.label || ''
+                  const value = context.parsed || 0
+                  const total = (context.dataset.data || []).reduce((a, b) => a + b, 0)
+                  const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
+                  return `${label}: ${value} (${percentage}%)`
+                }
+              }
+            }
+          }
+        }
+      })
+    },
+
+    renderMetricsCompareChart() {
+      if (this.metricsCompareChart) this.metricsCompareChart.destroy()
+
+      const canvas = this.$refs.metricsCompareChartRef
+      if (!canvas) return
+
+      const levels = ['超级爆款', '潜力爆款', '常规款', '清货款']
+      const metricNames = ['销售热度', '价格接受度', '退款稳定性', '复购热度', '夜间爆发力']
+      const metricKeys = ['sales_heat', 'price_acceptance', 'refund_stability', 'repurchase_rate', 'night_burst']
+
+      // 计算各等级平均指标
+      const levelMetrics = {}
+      levels.forEach(level => {
+      const spus = this.spuList.filter(s => s.hotproduct_level === level)
+      if (spus.length > 0) {
+        levelMetrics[level] = metricKeys.map(key => {
+          const sum = spus.reduce((acc, spu) => acc + (Number(spu.metrics && spu.metrics[key]) || 0), 0)
+          return sum / spus.length
+        })
+      } else {
+        levelMetrics[level] = metricKeys.map(() => 0)
+      }
+      })
+
+      const baseColors = ['#ef4444', '#f59e0b', '#10b981', '#6b7280']
+      const datasets = levels.map((level, idx) => ({
+        label: level,
+        data: levelMetrics[level] || [],
+        backgroundColor: baseColors[idx] + '33',
+        borderColor: baseColors[idx],
+        borderWidth: 2
+      }))
+
+      this.metricsCompareChart = new Chart(canvas, {
+        type: 'radar',
+        data: {
+          labels: metricNames,
+          datasets
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          scales: {
+            r: {
+              beginAtZero: true,
+              max: 1,
+              ticks: { stepSize: 0.2 }
+            }
+          },
+          plugins: {
+            legend: { position: 'bottom' }
+          }
+        }
+      })
+    },
+
+    renderRadarChart() {
+      if (!this.selectedSpu) return
+      if (this.radarChart) this.radarChart.destroy()
+
+      const canvas = this.$refs.radarChartRef
+      if (!canvas) return
+
+      const metricNames = ['销售热度', '价格接受度', '退款稳定性', '复购热度', '夜间爆发力']
+      const metricKeys = ['sales_heat', 'price_acceptance', 'refund_stability', 'repurchase_rate', 'night_burst']
+      const data = metricKeys.map(key => Number(this.selectedSpu.metrics && this.selectedSpu.metrics[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,
+          scales: {
+            r: {
+              beginAtZero: true,
+              max: 1,
+              ticks: { stepSize: 0.2 }
+            }
+          },
+          plugins: {
+            legend: { display: false }
+          }
+        }
+      })
+    },
+
+    showDetail(spu) {
+      this.selectedSpu = spu
+      this.detailVisible = true
+    },
+
+    closeDetail() {
+      this.detailVisible = false
+      this.selectedSpu = 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'
+    },
+
+    getScoreColorClass(score) {
+      const s = Number(score) || 0
+      if (s >= 0.8) return 'score-red'
+      if (s >= 0.6) return 'score-yellow'
+      if (s >= 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(spu, key) {
+      if (!spu || !spu.metrics) return '-'
+      const val = spu.metrics[key]
+      return val != null ? String(val) : '-'
+    },
+
+    getRawData(spu, key) {
+      if (!spu || !spu.raw_data) return '-'
+      const val = spu.raw_data[key]
+      return val != null ? val : '-'
+    },
+
+    formatCurrency(val) {
+      if (val == null || val === '-') return '-'
+      const num = Number(val)
+      if (Number.isNaN(num)) return '-'
+      return '¥' + num.toFixed(2)
+    },
+
+    formatPercent(val) {
+      if (val == null || val === '-') return '-'
+      const num = Number(val)
+      if (Number.isNaN(num)) return '-'
+      return num.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 a = document.createElement('a')
+      a.href = url
+      a.download = `spu_hotproduct_results_${new Date().toISOString().slice(0, 10)}.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; }
+.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;
+}
+
+.metric-name {
+  font-size: 13px;
+  color: #606266;
+}
+
+.metric-score {
+  font-size: 16px;
+  font-weight: 700;
+  color: #303133;
+}
+
+.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;
+}
+</style>

+ 9 - 4
vue.config.js

@@ -9,7 +9,9 @@ const CompressionPlugin = require('compression-webpack-plugin')
 
 const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题
 
-const baseUrl = 'http://localhost:8080' // 后端接口
+const baseUrl = 'http://localhost:8080' // java后端接口
+
+const pyUrl = 'http://localhost:8085' // python后端接口
 
 const port = process.env.port || process.env.npm_config_port || 80 // 端口
 
@@ -35,12 +37,15 @@ module.exports = {
     open: true,
     proxy: {
       // detail: https://cli.vuejs.org/config/#devserver-proxy
+      [process.env.VUE_APP_PYTHON_API]: {
+        target: pyUrl,
+        changeOrigin: true,
+        pathRewrite: { ['^' + process.env.VUE_APP_PYTHON_API]: '' }
+      },      
       [process.env.VUE_APP_BASE_API]: {
         target: baseUrl,
         changeOrigin: true,
-        pathRewrite: {
-          ['^' + process.env.VUE_APP_BASE_API]: ''
-        }
+        pathRewrite: { ['^' + process.env.VUE_APP_BASE_API]: '' }
       },
       // springdoc proxy
       '^/v3/api-docs/(.*)': {