Jelajahi Sumber

生命周期部分数据总览与sku级分析

Zhu Jiaqi 4 bulan lalu
induk
melakukan
8f97bf8aa9

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
   "dependencies": {
     "@riophae/vue-treeselect": "0.4.0",
     "axios": "0.28.1",
+    "chart.js": "^2.9.4",
     "clipboard": "2.0.8",
     "core-js": "3.37.1",
     "echarts": "5.4.0",

+ 101 - 0
src/api/client.js

@@ -0,0 +1,101 @@
+import axios from 'axios'
+
+const http = axios.create({
+  baseURL: '/api',
+  timeout: 300000, // 5分钟超时,用于大数据量分析
+})
+
+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)
+  return data
+}
+
+export async function analyzeFile(file) {
+  // Upload file first
+  const uploadResult = await uploadFile(file)
+  if (!uploadResult.success) {
+    throw new Error(uploadResult.message || 'File upload failed')
+  }
+
+  // Analyze cached data
+  const { data } = await http.post('/sku-lifecycle/analyze')
+  return data
+}
+
+
+
+// export async function analyzeProductLifecycle(file) {
+//   const form = new FormData()
+//   form.append('file', file)
+//   // 不要手动设置 Content-Type,让浏览器自动添加 boundary
+//   const { data } = await http.post('/sku-lifecycle/analyze-product', form)
+//   return data
+// }
+
+export async function getResults() {
+  const { data } = await http.get('/sku-lifecycle/results')
+  return data
+}
+
+export async function getResultBySku(sku) {
+  const { data } = await http.get(`/sku-lifecycle/results/${encodeURIComponent(sku)}`)
+  return data
+}
+
+export async function getCacheInfo() {
+  const { data } = await http.get('/sku-lifecycle/cache/info')
+  return data
+}
+
+export async function clearBackendCache() {
+  const { data } = await http.delete('/sku-lifecycle/cache')
+}
+
+export async function simulateStrategy(payload) {
+  const { data } = await http.post('/sku-lifecycle/strategy-simulation', payload)
+  return data
+}
+
+export async function getInsights(payload) {
+  const { data } = await http.post('/sku-lifecycle/insights', payload)
+  return data
+}
+
+// 爆款分析相关API
+export async function uploadHotProductFile(file) {
+  const form = new FormData()
+  form.append('file', file)
+  const { data } = await http.post('/sku-lifecycle/upload', form)
+  return data
+}
+
+export async function analyzeHotProduct() {
+  const { data } = await http.post('/hotproduct/analyze')
+  return data
+}
+
+export async function analyzeHotProductWithFile(file) {
+  // Upload file first
+  const uploadResult = await uploadHotProductFile(file)
+  if (!uploadResult.success) {
+    throw new Error(uploadResult.message || 'File upload failed')
+  }
+
+  // Analyze cached data
+  return await analyzeHotProduct()
+}
+
+
+
+export async function getHotProductResults() {
+  const { data } = await http.get('/hotproduct/results')
+  return data
+}
+
+export async function getHotProductResultBySku(sku) {
+  const { data } = await http.get(`/hotproduct/results/${encodeURIComponent(sku)}`)
+  return data
+}

+ 142 - 0
src/store/analysis.js

@@ -0,0 +1,142 @@
+import * as api from '../api/client'
+
+const LS_KEY_RESULTS = 'analysis_results'
+const LS_KEY_SELECTED_SKU = 'analysis_selectedSku'
+
+function loadFromLocalStorage() {
+  try {
+    const raw = localStorage.getItem(LS_KEY_RESULTS)
+    const res = raw ? JSON.parse(raw) : {}
+    const sel = localStorage.getItem(LS_KEY_SELECTED_SKU) || ''
+    return { res, sel }
+  } catch (e) {
+    return { res: {}, sel: '' }
+  }
+}
+
+function saveToLocalStorage(results, selectedSku) {
+  try {
+    localStorage.setItem(LS_KEY_RESULTS, JSON.stringify(results || {}))
+    localStorage.setItem(LS_KEY_SELECTED_SKU, selectedSku || '')
+  } catch (e) {}
+}
+
+function pickFirstSku(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,
+    selectedSku: 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_SKU(state, payload) {
+    state.selectedSku = payload || ''
+  }
+}
+
+const actions = {
+  async uploadFile({ commit }, file) {
+    commit('SET_LOADING', true)
+    commit('SET_ERROR', null)
+    try {
+      const res = await api.uploadFile(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.analyzeFile(file)
+      const results = response && response.data ? response.data : {}
+      commit('SET_RESULTS', results)
+      const firstSku = pickFirstSku(results)
+      commit('SET_SELECTED_SKU', firstSku || '')
+      saveToLocalStorage(results, firstSku || '')
+    } 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.getResults()
+      if (results && !results.message) {
+        commit('SET_RESULTS', results)
+        let selectedSku = state.selectedSku
+        if (!selectedSku || !results[selectedSku]) {
+          selectedSku = pickFirstSku(results)
+          commit('SET_SELECTED_SKU', selectedSku)
+        }
+        saveToLocalStorage(results, selectedSku)
+      }
+    } catch (e) {
+      commit('SET_ERROR', (e && e.message) || '获取结果失败')
+    } finally {
+      commit('SET_LOADING', false)
+    }
+  },
+  selectSku({ commit, state }, sku) {
+    const next = sku || ''
+    commit('SET_SELECTED_SKU', next)
+    saveToLocalStorage(state.results, next)
+  },
+  clearResults({ commit }) {
+    commit('SET_RESULTS', {})
+    commit('SET_SELECTED_SKU', '')
+    commit('SET_ERROR', null)
+    saveToLocalStorage({}, '')
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+}

+ 3 - 1
src/store/index.js

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

+ 8 - 2
src/store/modules/permission.js

@@ -110,12 +110,18 @@ export function filterDynamicRoutes(routes) {
   return res
 }
 
+function normalizeViewPath(view) {
+  if (!view) return view
+  return view.replace('lifecycle/sku-detail', 'lifecycle/skuAnalysis')
+}
+
 export const loadView = (view) => {
+  const normalized = normalizeViewPath(view)
   if (process.env.NODE_ENV === 'development') {
-    return (resolve) => require([`@/views/${view}`], resolve)
+    return (resolve) => require([`@/views/${normalized}`], resolve)
   } else {
     // 使用 import 实现生产环境的路由懒加载
-    return () => import(`@/views/${view}`)
+    return () => import(`@/views/${normalized}`)
   }
 }
 

+ 27 - 0
src/utils/format.js

@@ -0,0 +1,27 @@
+export function formatCurrency(value) {
+  if (value == null || Number.isNaN(value)) return '-'
+  return `¥${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+}
+
+export function formatDate(dateStr) {
+  if (!dateStr) return '-'
+  try {
+    const date = new Date(dateStr)
+    return date.toLocaleDateString('zh-CN')
+  } catch {
+    return dateStr
+  }
+}
+
+export function downloadJson(obj, filename) {
+  const jsonStr = JSON.stringify(obj, null, 2)
+  const blob = new Blob([jsonStr], { type: 'application/json' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = filename
+  document.body.appendChild(a)
+  a.click()
+  document.body.removeChild(a)
+  URL.revokeObjectURL(url)
+}

+ 177 - 35
src/views/lifecycle/overview/index.vue

@@ -6,34 +6,29 @@
       <p class="page-desc">全局SKU生命周期分析概览,包含关键指标和趋势分析</p>
     </div>
 
-    <!-- 文件上传区域 -->
-    <el-card class="mb-20">
-      <div slot="header">
-        <span><i class="el-icon-upload"></i> 数据文件上传</span>
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="upload"
+          class="toolbar-upload"
+          :limit="1"
+          accept=".xlsx,.xls,.csv"
+          :http-request="customUpload"
+          :disabled="upload.isUploading"
+          :on-change="handleFileChange"
+          :before-upload="beforeUpload"
+          :auto-upload="false"
+          :show-file-list="false"
+        >
+          <el-button plain>上传文件</el-button>
+        </el-upload>
+        <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
+        <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
       </div>
-      <el-upload
-        ref="upload"
-        :limit="1"
-        accept=".xlsx,.xls,.csv"
-        :http-request="customUpload"
-        :disabled="upload.isUploading"
-        :on-progress="handleFileUploadProgress"
-        :before-upload="beforeUpload"
-        :auto-upload="false"
-        drag
-      >
-        <i class="el-icon-upload"></i>
-        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
-        <div class="el-upload__tip" slot="tip">
-          <el-checkbox v-model="upload.updateSupport" /> 是否覆盖已上传的文件
-          <div>只能上传xlsx/xls/csv文件,且不超过20MB</div>
-        </div>
-      </el-upload>
-      <div style="margin-top: 15px">
-        <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">立即上传并分析</el-button>
-        <el-button @click="resetUpload">重置</el-button>
-      </div>
-    </el-card>
+      <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
+      <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
+      <div class="toolbar-status muted" v-else>未上传</div>
+    </div>
 
     <!-- 关键指标卡片 -->
     <el-row :gutter="20" class="mb-20">
@@ -207,10 +202,21 @@ export default {
         // 设置上传的请求头部
         headers: { Authorization: 'Bearer ' + getToken() },
         // 上传的地址
-        url: process.env.VUE_APP_BASE_API + '/statistics/lifecycle/upload'
+        url: process.env.VUE_APP_BASE_API + '/statistics/lifecycle/upload',
+        // 文件名称
+        fileName: '',
+        // 已选择文件名称
+        pendingFileName: '',
+        // 是否忽略文件选择改变
+        ignoreFileChange: false,
       }
     }
   },
+  computed: {
+    hasResults() {
+      return Object.keys(this.results || {}).length > 0
+    }
+  },
   mounted() {
     this.$nextTick(() => {
       this.initCharts()
@@ -235,6 +241,15 @@ export default {
     window.removeEventListener('resize', this.handleResize)
   },
   methods: {
+    /** 文件选择改变处理 */
+    handleFileChange(file, fileList) {
+      if (this.upload.ignoreChange) 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' ||
@@ -267,7 +282,8 @@ export default {
         this.upload.isUploading = false
         if (response.code === 200) {
           this.$modal.msgSuccess('文件上传并分析成功')
-          // response.data 就是分析结果,格式为 { sku1: {...}, sku2: {...}, ... }
+          this.upload.fileName = this.upload.pendingFileName || file.name
+          this.upload.pendingFileName = ''
           this.results = response.data || {}
           this.calculateMetrics()
           this.$nextTick(() => {
@@ -278,11 +294,16 @@ export default {
           this.$modal.msgError(response.msg || '分析失败')
           options.onError(new Error(response.msg || '分析失败'))
         }
-        // 重置上传组件
-        this.$refs.upload.clearFiles()
+        if (this.$refs.upload) {
+          this.upload.ignoreFileChange = true
+          this.$refs.upload.clearFiles()
+          this.$nextTick(() => {
+            this.upload.ignoreFileChange = false
+          })
+        }
       }).catch(error => {
         this.upload.isUploading = false
-        const errorMsg = error.response?.data?.msg || error.message || '文件上传失败,请重试'
+        const errorMsg = (error && error.response && error.response.data && error.response.data.message) || error.message || '文件上传失败,请重试'
         this.$modal.msgError(errorMsg)
         options.onError(error)
       })
@@ -298,13 +319,13 @@ export default {
     },
     /** 重置上传 */
     resetUpload() {
-      this.$refs.upload.clearFiles()
+      if (this.$refs.upload) this.$refs.upload.clearFiles()
+      if (this.$refs.toolbarUpload) this.$refs.toolbarUpload.clearFiles()
     },
     /** 获取生命周期分析结果 */
     getList() {
       getLifecycleResults().then(response => {
         if (response.code === 200 && response.data) {
-          // response.data 就是分析结果,格式为 { sku1: {...}, sku2: {...}, ... }
           this.results = response.data || {}
           this.calculateMetrics()
           this.$nextTick(() => {
@@ -312,7 +333,6 @@ export default {
           })
         }
       }).catch(() => {
-        // 如果没有数据,不显示错误,只是不显示图表
         this.results = {}
       })
     },
@@ -691,6 +711,30 @@ export default {
         return prev ? Number(((c / prev) * 100).toFixed(1)) : 0
       })
       return { labels, values }
+    },
+    formatUploadDate(date) {
+      const d = date instanceof Date ? date : new Date(date)
+      if (Number.isNaN(d.getTime())) return ''
+      const y = d.getFullYear()
+      const m = String(d.getMonth() + 1).padStart(2, '0')
+      const day = String(d.getDate()).padStart(2, '0')
+      return `${y}-${m}-${day}`
+    },
+    exportResults() {
+      if (!this.hasResults) {
+        this.$modal.msgError('暂无可导出的分析结果')
+        return
+      }
+      const payload = JSON.stringify(this.results || {}, null, 2)
+      const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
+      const url = URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url
+      a.download = `sku_lifecycle_results_${this.formatUploadDate(new Date())}.json`
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+      URL.revokeObjectURL(url)
     }
   }
 }
@@ -727,6 +771,104 @@ export default {
   margin-bottom: 20px;
 }
 
+.upload-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #ffffff;
+  border: 1px solid #e6eaf2;
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.toolbar-upload ::v-deep .el-upload {
+  display: inline-flex;
+}
+
+.toolbar-status {
+  font-size: 13px;
+  color: #16a34a;
+  background: #f0fdf4;
+  border: 1px solid #dcfce7;
+  border-radius: 6px;
+  padding: 6px 10px;
+}
+
+.toolbar-status.muted {
+  color: #6b7280;
+  background: #f8fafc;
+  border-color: #e2e8f0;
+}
+
+.upload-card {
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.upload-card .el-card__header {
+  padding: 12px 16px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.upload-row {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.upload-compact {
+  flex: 1;
+  max-width: 260px;
+}
+
+.upload-compact ::v-deep .el-upload-dragger {
+  width: 100%;
+  height: 140px;
+  padding: 10px 12px;
+  border-radius: 8px;
+}
+
+.upload-compact ::v-deep .el-icon-upload {
+  font-size: 26px;
+  margin-bottom: 4px;
+}
+
+.upload-compact ::v-deep .el-upload__text {
+  font-size: 13px;
+}
+
+.upload-compact ::v-deep .el-upload__tip {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.upload-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: 120px;
+}
+
+::v-deep .el-card {
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+::v-deep .el-card__header {
+  border-bottom: 1px solid #eef2f7;
+}
+
 .stat-card {
   .stat-content {
     display: flex;

+ 1118 - 0
src/views/lifecycle/skuAnalysis/index.vue

@@ -0,0 +1,1118 @@
+<template>
+  <div class="app-container">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2><i class="el-icon-data-analysis"></i> SKU生命周期详细分析</h2>
+      <p class="page-desc">单个SKU的完整生命周期分析,包含趋势、指标和阶段详情</p>
+    </div>
+
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="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>
+
+    <!-- SKU选择 + 基础信息板块 -->
+    <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">选择SKU:</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 sku-select"
+                  v-model="selectedSku">
+            <option v-for="k in skuOptions" :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">SKU编码</p>
+          <p class="text-lg font-medium text-gray-800 truncate">{{ selectedSku }}</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">SKU生命周期趋势图</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>
+          不完整生命周期SKU - 完整性评估得分:{{ 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 { getToken } from '@/utils/auth'
+import { Chart } from 'chart.js'
+import { formatCurrency, formatDate } from '../../../utils/format'
+
+const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
+
+export default {
+  name: 'LifecycleSkuDetail',
+  data() {
+    return {
+      trendChart: null,
+      stageCompareChart: null,
+      upload: {
+        // 是否显示弹出层
+        open: false,
+        // 弹出层标题
+        title: '',
+        // 是否禁用上传
+        isUploading: false,
+        // 是否更新已经存在的文件
+        updateSupport: 0,
+        // 设置上传的请求头部
+        headers: { Authorization: 'Bearer ' + getToken() },
+        // 上传的地址
+        url: process.env.VUE_APP_BASE_API + '/statistics/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.analysis
+      return (state && state.results) || {}
+    },
+    selectedSku: {
+      get() {
+        const state = this.$store && this.$store.state && this.$store.state.analysis
+        return (state && state.selectedSku) || ''
+      },
+      set(value) {
+        if (this.$store) {
+          this.$store.dispatch('analysis/selectSku', value)
+        }
+      }
+    },
+    skuOptions() {
+      return Object.keys(this.results || {}).filter(k => k !== '_analysis_summary_')
+    },
+    detail() {
+      return (this.results && this.selectedSku && this.results[this.selectedSku]) || 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.selectedSku || !this.results[this.selectedSku]) {
+      const first = this.skuOptions[0] || ''
+      if (first) this.$store.dispatch('analysis/selectSku', 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.selectedSku]) {
+        const first = this.skuOptions[0] || ''
+        if (first) this.$store.dispatch('analysis/selectSku', first)
+      }
+    }
+  },
+  methods: {
+    /** 获取生命周期分析结果 */
+    getList() {
+      getLifecycleResults().then(response => {
+        if (response && response.code === 200 && response.data) {
+          const results = response.data || {}
+          this.$store.commit('analysis/SET_RESULTS', results)
+          const firstSku = this.pickFirstSku(results)
+          this.$store.commit('analysis/SET_SELECTED_SKU', firstSku)
+          this.$nextTick(() => {
+            this.renderTrend()
+            this.renderStageCompare()
+          })
+        }
+      }).catch(() => {
+        this.$store.commit('analysis/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 isLt20M = file.size / 1024 / 1024 < 20
+
+      if (!isExcel) {
+        this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
+        return false
+      }
+      if (!isLt20M) {
+        this.$modal.msgError('上传文件大小不能超过 20MB!')
+        return false
+      }
+      return true
+    },
+    handleFileUploadProgress() {
+      this.upload.isUploading = true
+    },
+    customUpload(options) {
+      const file = options.file
+      this.upload.isUploading = true
+      uploadAndAnalyze(file).then(response => {
+        this.upload.isUploading = false
+        if (response && response.code === 200) {
+          const results = response.data || {}
+          this.$store.commit('analysis/SET_RESULTS', results)
+          const firstSku = this.pickFirstSku(results)
+          this.$store.commit('analysis/SET_SELECTED_SKU', firstSku)
+          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 = `sku_lifecycle_results_${this.formatUploadDate(new Date())}.json`
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+      URL.revokeObjectURL(url)
+    },
+    pickFirstSku(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); }
+
+.sku-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>