Kaynağa Gözat

生命周期模块生命周期分析、爆品分析sku与spu层级整合,数据上传模块初版

Zhu Jiaqi 1 ay önce
ebeveyn
işleme
bc4ecaf3d9

Dosya farkı çok büyük olduğundan ihmal edildi
+ 368 - 289
src/views/lifecycle/hotProductAnalysis/index.vue


+ 1284 - 0
src/views/lifecycle/lifecycleAnalysis/index.vue

@@ -0,0 +1,1284 @@
+<template>
+  <div class="app-container">
+    <div class="page-header">
+      <div>
+        <h2><i class="el-icon-data-analysis"></i> 生命周期分析</h2>
+        <p class="page-desc">支持在 SKU 和 SPU 两个维度查看生命周期分析结果</p>
+      </div>
+      <div class="analysis-switch">
+        <button
+          v-for="item in analysisTabs"
+          :key="item.value"
+          type="button"
+          class="switch-btn"
+          :class="{ active: activeView === item.value }"
+          @click="switchAnalysis(item.value)"
+        >
+          {{ item.label }}
+        </button>
+      </div>
+    </div>
+
+    <div class="upload-toolbar">
+      <div class="toolbar-left">
+        <el-upload
+          ref="toolbarUpload"
+          class="toolbar-upload"
+          :limit="1"
+          accept=".xlsx,.xls,.csv"
+          :http-request="customUpload"
+          :disabled="upload.isUploading"
+          :on-change="handleFileChange"
+          :before-upload="beforeUpload"
+          :auto-upload="false"
+          :show-file-list="false"
+        >
+          <el-button plain>上传文件</el-button>
+        </el-upload>
+        <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始分析</el-button>
+        <el-button type="success" :disabled="!hasResults" @click="exportResults">导出分析</el-button>
+      </div>
+      <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
+      <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
+      <div class="toolbar-status muted" v-else>未上传</div>
+    </div>
+
+    <template v-if="hasResults && detail">
+      <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
+        <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
+          <div class="flex items-center gap-3">
+            <label class="text-sm font-medium text-gray-700">选择{{ currentConfig.entityName }}:</label>
+            <select
+              v-model="selectedValue"
+              class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm entity-select"
+            >
+              <option v-for="item in entityOptions" :key="item" :value="item">{{ item }}</option>
+            </select>
+          </div>
+
+          <div class="flex items-center gap-2">
+            <span class="text-sm font-medium text-gray-700">当前阶段:</span>
+            <span class="px-3 py-1 bg-gradient-to-r from-yellow-100 to-amber-100 text-yellow-800 rounded-full text-sm font-medium flex items-center gap-2">
+              {{ currentStage }}
+            </span>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+          <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+            <p class="text-xs text-gray-500 uppercase tracking-wide">{{ currentConfig.entityDisplayLabel }}</p>
+            <p class="text-lg font-medium text-gray-800 truncate">{{ selectedValue }}</p>
+          </div>
+          <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+            <p class="text-xs text-gray-500 uppercase tracking-wide">生命周期完整性</p>
+            <p class="text-lg font-medium" :class="detail && detail.is_complete ? 'text-green-700' : 'text-orange-600'">
+              {{ detail && detail.is_complete ? '完整' : '不完整' }}
+              <span v-if="detail && detail.completeness_score != null" class="ml-2 text-sm text-gray-500">
+                (得分: {{ detail.completeness_score }}/100)
+              </span>
+            </p>
+          </div>
+          <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+            <p class="text-xs text-gray-500 uppercase tracking-wide">总销售额</p>
+            <p class="text-lg font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</p>
+          </div>
+          <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+            <p class="text-xs text-gray-500 uppercase tracking-wide">总销量</p>
+            <p class="text-lg font-medium text-gray-800">{{ totalQty }}</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
+        <div class="flex justify-between items-center mb-6">
+          <h3 class="text-lg font-semibold text-gray-800">{{ currentConfig.entityName }}生命周期趋势图</h3>
+          <div class="flex items-center gap-3">
+            <div class="flex items-center gap-2">
+              <span class="w-3 h-3 rounded-full bg-blue-500"></span>
+              <span class="text-sm text-gray-600">销售额</span>
+            </div>
+            <div class="flex items-center gap-2">
+              <span class="w-3 h-3 rounded-full bg-gray-500"></span>
+              <span class="text-sm text-gray-600">销量</span>
+            </div>
+          </div>
+        </div>
+        <div class="h-96">
+          <canvas ref="lifeCycleTrendRef"></canvas>
+        </div>
+        <div class="mt-4 space-y-2">
+          <div v-if="detail && detail.is_complete" class="text-sm">
+            <span :class="hasFourStages ? 'text-green-700 font-medium' : 'text-red-600 font-medium'">
+              <i :class="hasFourStages ? 'fa fa-check-circle' : 'fa fa-exclamation-triangle'" class="mr-1"></i>
+              {{ hasFourStages ? '阶段划分完整:已包含引入/成长/成熟/衰退四个阶段' : '阶段划分不完整:未包含所有四个标准阶段' }}
+            </span>
+          </div>
+          <div v-if="detail && !detail.is_complete && detail.completeness_score != null" class="text-sm text-orange-600">
+            <i class="fa fa-info-circle mr-1"></i>
+            不完整生命周期{{ currentConfig.entityName }} - 完整性评估得分:{{ detail.completeness_score }}/100(需要达到 80 分以上才能被评为完整周期)
+          </div>
+        </div>
+      </div>
+
+      <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-20">
+        <div class="bg-white rounded-xl p-6 shadow-sm space-y-6">
+          <h3 class="text-lg font-semibold text-gray-800">基本指标</h3>
+          <div>
+            <div class="flex justify-between mb-2">
+              <span class="text-sm text-gray-500">总销售额</span>
+              <span class="text-sm font-medium text-gray-800">{{ formatCurrency(totalRevenue) }}</span>
+            </div>
+            <div class="progress-bar"><div class="progress-value bg-blue-500" :style="{ width: revenuePct + '%' }"></div></div>
+          </div>
+          <div>
+            <div class="flex justify-between mb-2">
+              <span class="text-sm text-gray-500">总销量</span>
+              <span class="text-sm font-medium text-gray-800">{{ totalQty }}</span>
+            </div>
+            <div class="progress-bar"><div class="progress-value bg-gray-500" :style="{ width: qtyPct + '%' }"></div></div>
+          </div>
+          <div class="grid grid-cols-2 gap-4 pt-2">
+            <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+              <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销售额</p>
+              <p class="text-lg font-medium text-gray-800">{{ formatCurrency(detail && detail.peak_revenue) }}</p>
+              <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_revenue_date) }}</p>
+            </div>
+            <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+              <p class="text-xs text-gray-500 uppercase tracking-wide">峰值销量</p>
+              <p class="text-lg font-medium text-gray-800">{{ detail && detail.peak_quantity != null ? detail.peak_quantity : '-' }}</p>
+              <p class="text-xs text-gray-400 mt-1">{{ formatDate(detail && detail.peak_quantity_date) }}</p>
+            </div>
+          </div>
+          <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
+            <p class="text-xs text-gray-500 uppercase tracking-wide">当前阶段说明</p>
+            <p class="text-sm text-gray-700">
+              处于 <span class="font-medium">{{ currentStage }}</span>,已持续
+              <span class="font-medium">{{ currentStageDurationDays }}</span> 天。
+            </p>
+            <p class="text-xs text-gray-600 mt-1">{{ detail && detail.details ? detail.details : '-' }}</p>
+            <p v-if="detail && !detail.is_complete && detail.next_stage_prediction" class="text-xs text-blue-600 mt-1">
+              下一阶段预测:{{ detail.next_stage_prediction }}
+            </p>
+          </div>
+        </div>
+
+        <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
+          <h3 class="text-lg font-semibold text-gray-800 mb-6">阶段分析</h3>
+          <div class="overflow-x-auto">
+            <table class="min-w-full divide-y divide-gray-200">
+              <thead>
+                <tr>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">阶段</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销售额</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销售额占比</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总销量</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">销量占比</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销售额</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日均销量</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">持续天数</th>
+                  <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">起止日期</th>
+                </tr>
+              </thead>
+              <tbody class="bg-white divide-y divide-gray-200">
+                <tr v-for="(stats, stage) in displayStageStats" :key="stage">
+                  <td class="px-4 py-3 text-sm"><span class="lifecycle-stage" :class="stageClass(stage)">{{ stage }}</span></td>
+                  <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ formatCurrency(stats.totalRevenue) }}</td>
+                  <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.revenuePercentage) }}</td>
+                  <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ stats.totalQuantity != null ? stats.totalQuantity : '-' }}</td>
+                  <td class="px-4 py-3 text-sm text-gray-700">{{ formatPercent(stats.quantityPercentage) }}</td>
+                  <td class="px-4 py-3 text-sm text-gray-700">{{ formatCurrency(stats.avgDailyRevenue) }}</td>
+                  <td class="px-4 py-3 text-sm text-gray-700">{{ stats.avgDailyQuantity != null ? stats.avgDailyQuantity : '-' }}</td>
+                  <td class="px-4 py-3 text-sm text-gray-700">{{ stats.durationDays }}</td>
+                  <td class="px-4 py-3 text-xs text-gray-600">{{ formatDate(stats.startDate) }} ~ {{ formatDate(stats.endDate) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+          <div class="mt-8">
+            <div class="flex justify-between items-center mb-4">
+              <h4 class="text-base font-semibold text-gray-800">阶段对比条形图</h4>
+              <div class="text-xs text-gray-500">销售额/销量/持续天数</div>
+            </div>
+            <div class="h-64">
+              <canvas ref="stageCompareRef"></canvas>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
+        <div class="flex justify-between items-center mb-6">
+          <h3 class="text-lg font-semibold text-gray-800">完整性评估细项</h3>
+          <div class="text-sm" :class="detail && detail.is_complete ? 'text-green-600' : 'text-orange-600'">
+            总分 {{ detail && detail.completeness_score != null ? detail.completeness_score : 0 }}/100
+            <span class="ml-2">{{ detail && detail.is_complete ? '(已达完整标准 80 分)' : '(未达完整标准)' }}</span>
+          </div>
+        </div>
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          <div
+            v-for="item in breakdownItems"
+            :key="item.key"
+            class="flex items-center justify-between border border-gray-200 rounded-lg px-4 py-3 bg-gray-50"
+          >
+            <div class="flex items-center gap-3">
+              <span :class="item.hit ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-3 py-1 rounded-full text-xs font-medium">
+                {{ item.hit ? '命中' : '未命中' }}
+              </span>
+              <span class="text-sm text-gray-800">{{ item.label }}</span>
+            </div>
+            <span class="text-xs text-gray-500 font-medium">权重 {{ item.weight }}</span>
+          </div>
+        </div>
+      </div>
+    </template>
+
+    <div v-else class="empty-state shadow-sm">
+      <i class="el-icon-upload2"></i>
+      <p>暂无{{ currentConfig.entityName }}生命周期分析结果,请先上传文件并开始分析。</p>
+    </div>
+  </div>
+</template>
+
+<script>
+import { analyzeFile, analyzeSpuFile, getResults, getSpuResults } from '@/api/client'
+import { Chart } from 'chart.js'
+import { formatCurrency, formatDate } from '../../../utils/format'
+
+const STORAGE_KEY = 'lifecycle_analysis_active_view'
+const stageOrder = ['引入期', '成长期', '成熟期', '衰退期']
+const breakdownMapping = [
+  { key: 'sufficient_time', label: '时间长度≥120天' },
+  { key: 'reasonable_peak_position', label: '峰值位置25%-75%' },
+  { key: 'significant_growth', label: '显著增长(额≥100% 或 量≥80%)' },
+  { key: 'noticeable_decline', label: '明显衰退(额≤-35% 或 量≤-30%)' },
+  { key: 'has_lifecycle_shape', label: '生命周期形状(额CV≥0.3 且 量CV≥0.25)' },
+  { key: 'peak_significance', label: '峰值显著性(≥均值1.8x)' },
+  { key: 'data_quality_check', label: '数据质量(长度≥120,峰值>0,增/退均出现)' },
+  { key: 'cycle_completeness', label: '周期完整性(时间/峰值/增长/衰退/形状)' },
+  { key: 'trend_consistency', label: '趋势一致性(增差≤0.8 或 退差≤0.4 或同向)' }
+]
+
+const ANALYSIS_CONFIG = {
+  sku: {
+    label: 'SKU分析',
+    entityName: 'SKU',
+    entityDisplayLabel: 'SKU编码',
+    moduleName: 'analysis',
+    selectAction: 'analysis/selectSku',
+    setSelectedMutation: 'analysis/SET_SELECTED_SKU',
+    setResultsMutation: 'analysis/SET_RESULTS',
+    selectedKey: 'selectedSku',
+    maxSizeMB: 300,
+    exportPrefix: 'sku_lifecycle_results',
+    fetchResults: getResults,
+    analyze: analyzeFile
+  },
+  spu: {
+    label: 'SPU分析',
+    entityName: 'SPU',
+    entityDisplayLabel: 'SPU名称',
+    moduleName: 'spuAnalysis',
+    selectAction: 'spuAnalysis/selectSpu',
+    setSelectedMutation: 'spuAnalysis/SET_SELECTED_SPU',
+    setResultsMutation: 'spuAnalysis/SET_RESULTS',
+    selectedKey: 'selectedSpu',
+    maxSizeMB: 500,
+    exportPrefix: 'spu_lifecycle_results',
+    fetchResults: getSpuResults,
+    analyze: analyzeSpuFile
+  }
+}
+
+function createUploadState() {
+  return {
+    isUploading: false,
+    fileName: '',
+    pendingFileName: '',
+    ignoreFileChange: false
+  }
+}
+
+export default {
+  name: 'LifecycleAnalysis',
+  data() {
+    return {
+      activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
+      trendChart: null,
+      stageCompareChart: null,
+      uploads: {
+        sku: createUploadState(),
+        spu: createUploadState()
+      }
+    }
+  },
+  computed: {
+    analysisTabs() {
+      return [
+        { label: 'SKU分析', value: 'sku' },
+        { label: 'SPU分析', value: 'spu' }
+      ]
+    },
+    currentConfig() {
+      return ANALYSIS_CONFIG[this.activeView]
+    },
+    upload() {
+      return this.uploads[this.activeView]
+    },
+    currentState() {
+      const state = this.$store && this.$store.state && this.$store.state[this.currentConfig.moduleName]
+      return state || {}
+    },
+    results() {
+      return this.currentState.results || {}
+    },
+    hasResults() {
+      return Object.keys(this.results || {}).length > 0
+    },
+    selectedValue: {
+      get() {
+        return this.currentState[this.currentConfig.selectedKey] || ''
+      },
+      set(value) {
+        if (this.$store) {
+          this.$store.dispatch(this.currentConfig.selectAction, value)
+        }
+      }
+    },
+    entityOptions() {
+      return Object.keys(this.results || {}).filter(key => key !== '_analysis_summary_')
+    },
+    detail() {
+      return (this.results && this.selectedValue && this.results[this.selectedValue]) || null
+    },
+    currentStage() {
+      return (this.detail && this.detail.current_stage) || '-'
+    },
+    currentStageDurationDays() {
+      const stats = (this.detail && this.detail.stage_statistics) || {}
+      const currentStage = this.detail && this.detail.current_stage
+      if (currentStage && stats[currentStage] && stats[currentStage].durationDays != null) {
+        return stats[currentStage].durationDays
+      }
+      return '-'
+    },
+    stageStats() {
+      return (this.detail && this.detail.stage_statistics) || {}
+    },
+    displayStageStats() {
+      const detail = this.detail || {}
+      const stats = this.stageStats || {}
+      const revenue = detail.revenue_series || detail.smoothed_revenue || []
+      const qty = detail.quantity_series || detail.smoothed_quantity || []
+      const rawDates = detail.date_series || []
+      const labels = rawDates.map(date => formatDate(date))
+
+      if (detail.is_complete) {
+        const boundaries = (detail.stage_boundaries || []).map(item => formatDate(item.date))
+        const indices = boundaries
+          .map(date => labels.indexOf(date))
+          .filter(index => index >= 0)
+          .sort((a, b) => a - b)
+
+        if (indices.length >= 3) {
+          const ranges = [
+            { name: stageOrder[0], start: 0, end: indices[0] },
+            { name: stageOrder[1], start: indices[0], end: indices[1] },
+            { name: stageOrder[2], start: indices[1], end: indices[2] },
+            { name: stageOrder[3], start: indices[2], end: labels.length - 1 }
+          ]
+          const totalRevenue = revenue.reduce((sum, value) => sum + (Number(value) || 0), 0)
+          const totalQty = qty.reduce((sum, value) => sum + (Number(value) || 0), 0)
+          const built = {}
+
+          ranges.forEach(range => {
+            const revenueSum = revenue.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
+            const qtySum = qty.slice(range.start, range.end + 1).reduce((sum, value) => sum + (Number(value) || 0), 0)
+            const days = Math.max(0, range.end - range.start + 1)
+
+            built[range.name] = {
+              totalRevenue: revenueSum,
+              revenuePercentage: totalRevenue ? revenueSum / totalRevenue * 100 : 0,
+              totalQuantity: qtySum,
+              quantityPercentage: totalQty ? qtySum / totalQty * 100 : 0,
+              avgDailyRevenue: days ? revenueSum / days : 0,
+              avgDailyQuantity: days ? qtySum / days : 0,
+              durationDays: days,
+              startDate: rawDates[range.start],
+              endDate: rawDates[range.end]
+            }
+          })
+
+          return built
+        }
+
+        if (stageOrder.every(stage => stats[stage])) {
+          return stats
+        }
+      }
+
+      const actual = {}
+      const keys = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+
+      keys.forEach(key => {
+        const value = stats[key] || {}
+        let duration = value.durationDays
+        const startIndex = labels.indexOf(formatDate(value.startDate))
+        const endIndex = labels.indexOf(formatDate(value.endDate))
+        if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
+          duration = Math.max(0, endIndex - startIndex + 1)
+        }
+        actual[key] = Object.assign({}, value, { durationDays: duration })
+      })
+
+      return actual
+    },
+    breakdownItems() {
+      const detail = this.detail || {}
+      const completionDetails = detail.completion_details || {}
+      return breakdownMapping.map(item => {
+        const current = completionDetails[item.key] || {}
+        return {
+          key: item.key,
+          label: item.label,
+          hit: !!current.hit,
+          weight: current.weight != null ? current.weight : '-'
+        }
+      })
+    },
+    hasFourStages() {
+      return stageOrder.every(stage => !!this.stageStats[stage])
+    },
+    totalRevenue() {
+      if (this.detail && this.detail.total_revenue != null) return this.detail.total_revenue
+      return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalRevenue || 0), 0)
+    },
+    totalQty() {
+      if (this.detail && this.detail.total_quantity != null) return this.detail.total_quantity
+      return Object.values(this.stageStats || {}).reduce((sum, value) => sum + (value.totalQuantity || 0), 0)
+    },
+    revenuePct() {
+      return 100
+    },
+    qtyPct() {
+      return 100
+    }
+  },
+  mounted() {
+    this.initCurrentView()
+  },
+  beforeDestroy() {
+    this.destroyCharts()
+  },
+  watch: {
+    activeView() {
+      localStorage.setItem(STORAGE_KEY, this.activeView)
+      this.resetUploaderSelection()
+      this.initCurrentView()
+    },
+    detail() {
+      this.$nextTick(() => {
+        this.renderTrend()
+        this.renderStageCompare()
+      })
+    },
+    results() {
+      this.ensureCurrentSelection()
+    }
+  },
+  methods: {
+    formatCurrency,
+    formatDate,
+    switchAnalysis(view) {
+      if (view === this.activeView) return
+      this.activeView = view
+    },
+    initCurrentView() {
+      if (!this.hasResults) {
+        this.getList()
+        return
+      }
+
+      this.ensureCurrentSelection()
+      this.$nextTick(() => {
+        this.renderTrend()
+        this.renderStageCompare()
+      })
+    },
+    getCurrentUploadState() {
+      return this.uploads[this.activeView]
+    },
+    extractResultsPayload(response) {
+      if (!response) return null
+      if (response.success && response.data) return response.data
+      if (response.code === 200 && response.data) return response.data
+      if (response.data && typeof response.data === 'object') return response.data
+      if (!response.message && !response.msg && typeof response === 'object') return response
+      return null
+    },
+    ensureCurrentSelection() {
+      if (!this.hasResults) {
+        this.selectedValue = ''
+        this.destroyCharts()
+        return
+      }
+
+      if (!this.selectedValue || !this.results[this.selectedValue]) {
+        const first = this.pickFirstValue(this.results)
+        if (first) {
+          this.selectedValue = first
+        }
+      }
+    },
+    getList() {
+      this.currentConfig.fetchResults().then(response => {
+        const results = this.extractResultsPayload(response)
+        if (results) {
+          this.$store.commit(this.currentConfig.setResultsMutation, results)
+          const first = this.pickFirstValue(results)
+          this.$store.commit(this.currentConfig.setSelectedMutation, first)
+          this.$nextTick(() => {
+            this.renderTrend()
+            this.renderStageCompare()
+          })
+        } else {
+          this.$store.commit(this.currentConfig.setResultsMutation, {})
+        }
+      }).catch(() => {
+        this.$store.commit(this.currentConfig.setResultsMutation, {})
+        this.destroyCharts()
+      })
+    },
+    handleFileChange(file, fileList) {
+      const upload = this.getCurrentUploadState()
+      if (upload.ignoreFileChange) return
+      if (!fileList || fileList.length === 0 || !file || !file.raw) return
+
+      upload.pendingFileName = file.name
+      upload.fileName = ''
+    },
+    beforeUpload(file) {
+      const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
+        file.type === 'application/vnd.ms-excel' ||
+        file.type === 'text/csv' ||
+        file.name.endsWith('.xlsx') ||
+        file.name.endsWith('.xls') ||
+        file.name.endsWith('.csv')
+      const isLtLimit = file.size / 1024 / 1024 < this.currentConfig.maxSizeMB
+
+      if (!isExcel) {
+        this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式')
+        return false
+      }
+      if (!isLtLimit) {
+        this.$modal.msgError(`上传文件大小不能超过 ${this.currentConfig.maxSizeMB}MB`)
+        return false
+      }
+      return true
+    },
+    customUpload(options) {
+      const file = options.file
+      const upload = this.getCurrentUploadState()
+      upload.isUploading = true
+
+      this.currentConfig.analyze(file).then(response => {
+        upload.isUploading = false
+        if (response && response.success) {
+          const results = response.data || {}
+          this.$store.commit(this.currentConfig.setResultsMutation, results)
+          const first = this.pickFirstValue(results)
+          this.$store.commit(this.currentConfig.setSelectedMutation, first)
+          upload.fileName = upload.pendingFileName || file.name
+          upload.pendingFileName = ''
+          this.$modal.msgSuccess(`${this.currentConfig.entityName}生命周期分析完成`)
+          this.$nextTick(() => {
+            this.renderTrend()
+            this.renderStageCompare()
+          })
+          options.onSuccess(response)
+        } else {
+          const message = response && (response.msg || response.message) ? response.msg || response.message : '分析失败'
+          this.$modal.msgError(message)
+          options.onError(new Error(message))
+        }
+      }).catch(error => {
+        upload.isUploading = false
+        const message = (error && error.message) || '文件上传失败,请重试'
+        this.$modal.msgError(message)
+        options.onError(error)
+      }).finally(() => {
+        this.resetUploaderSelection()
+      })
+    },
+    submitUpload() {
+      const target = this.$refs.toolbarUpload
+      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      if (!fileList || fileList.length === 0) {
+        this.$modal.msgError('请选择要上传的文件')
+        return
+      }
+      target.submit()
+    },
+    resetUploaderSelection() {
+      if (!this.$refs.toolbarUpload) return
+      const upload = this.getCurrentUploadState()
+      upload.ignoreFileChange = true
+      this.$refs.toolbarUpload.clearFiles()
+      this.$nextTick(() => {
+        upload.ignoreFileChange = false
+      })
+    },
+    formatPercent(value) {
+      if (value == null || Number.isNaN(value)) return '-'
+      return `${Number(value).toFixed(1)}%`
+    },
+    stageClass(stage) {
+      if (!stage) return ''
+      if (stage.indexOf('引入') >= 0 || stage.indexOf('导入') >= 0) return 'intro'
+      if (stage.indexOf('成长') >= 0) return 'growth'
+      if (stage.indexOf('成熟') >= 0) return 'maturity'
+      if (stage.indexOf('衰退') >= 0) return 'decline'
+      return ''
+    },
+    normalizeDate(dateStr) {
+      if (!dateStr) return ''
+      const match = String(dateStr).match(/(\d{4}-\d{2}-\d{2})/)
+      if (match) return match[1]
+      const parts = String(dateStr).split('T')
+      return parts[0].split(' ')[0]
+    },
+    destroyCharts() {
+      if (this.trendChart) {
+        this.trendChart.destroy()
+        this.trendChart = null
+      }
+      if (this.stageCompareChart) {
+        this.stageCompareChart.destroy()
+        this.stageCompareChart = null
+      }
+    },
+    renderTrend() {
+      const canvas = this.$refs.lifeCycleTrendRef
+      if (!canvas || !this.detail) {
+        if (this.trendChart) {
+          this.trendChart.destroy()
+          this.trendChart = null
+        }
+        return
+      }
+
+      const detail = this.detail || {}
+      const revenue = detail.smoothed_revenue || detail.revenue_series || []
+      const qty = detail.smoothed_quantity || detail.quantity_series || []
+      const rawDates = detail.date_series || []
+      const labels = rawDates.map(date => formatDate(date))
+      const boundaries = (detail.stage_boundaries || []).map(boundary => ({
+        type: boundary.type,
+        date: boundary.date ? formatDate(boundary.date) : null,
+        index: boundary.index
+      }))
+
+      const stageColors = {
+        [stageOrder[0]]: 'rgba(59,130,246,0.08)',
+        [stageOrder[1]]: 'rgba(16,185,129,0.10)',
+        [stageOrder[2]]: 'rgba(245,158,11,0.10)',
+        [stageOrder[3]]: 'rgba(239,68,68,0.08)'
+      }
+
+      const segments = []
+      const stagesMap = detail.stages_map || []
+
+      if (stagesMap.length > 0 && stagesMap.length === labels.length) {
+        let currentStage = stagesMap[0]
+        let segmentStart = 0
+
+        for (let i = 1; i < stagesMap.length; i++) {
+          if (stagesMap[i] !== currentStage) {
+            if (currentStage && stageColors[currentStage]) {
+              segments.push({
+                start: segmentStart,
+                end: i - 1,
+                stage: currentStage
+              })
+            }
+            currentStage = stagesMap[i]
+            segmentStart = i
+          }
+        }
+
+        if (currentStage && stageColors[currentStage] && segmentStart < stagesMap.length) {
+          segments.push({
+            start: segmentStart,
+            end: stagesMap.length - 1,
+            stage: currentStage
+          })
+        }
+      } else {
+        const stats = this.displayStageStats || {}
+        const orderedStages = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+        orderedStages.forEach(stage => {
+          const startIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].startDate))
+          const endIndex = labels.indexOf(formatDate(stats[stage] && stats[stage].endDate))
+          if (startIndex >= 0 && endIndex >= 0 && endIndex >= startIndex) {
+            segments.push({ start: startIndex, end: endIndex, stage })
+          }
+        })
+      }
+
+      const peakRevenueDate = detail.peak_revenue_date
+      const peakQtyDate = detail.peak_quantity_date
+      let peakRevenueIndex = -1
+      let peakQtyIndex = -1
+
+      if (peakRevenueDate && rawDates.length > 0) {
+        const targetDate = this.normalizeDate(peakRevenueDate)
+        peakRevenueIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        if (peakRevenueIndex < 0 && detail.revenue_peak_idx != null && detail.revenue_peak_idx < rawDates.length) {
+          peakRevenueIndex = detail.revenue_peak_idx
+        }
+      }
+
+      if (peakQtyDate && rawDates.length > 0) {
+        const targetDate = this.normalizeDate(peakQtyDate)
+        peakQtyIndex = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        if (peakQtyIndex < 0 && detail.quantity_peak_idx != null && detail.quantity_peak_idx < rawDates.length) {
+          peakQtyIndex = detail.quantity_peak_idx
+        }
+      }
+
+      const boundaryIndices = []
+      boundaries.forEach(boundary => {
+        let index = -1
+        if (boundary.index != null && boundary.index >= 0 && boundary.index < rawDates.length) {
+          index = boundary.index
+        } else if (boundary.date) {
+          const targetDate = this.normalizeDate(boundary.date)
+          index = rawDates.findIndex(date => this.normalizeDate(date) === targetDate)
+        }
+
+        if (index >= 0 && index < rawDates.length) {
+          boundaryIndices.push({ index, type: boundary.type })
+        }
+      })
+
+      if (this.trendChart) this.trendChart.destroy()
+      this.trendChart = new Chart(canvas, {
+        type: 'line',
+        data: {
+          labels,
+          datasets: [
+            {
+              label: '销售额',
+              data: revenue,
+              borderColor: '#3b82f6',
+              backgroundColor: 'rgba(59,130,246,0.15)',
+              lineTension: 0.25,
+              pointRadius: 0,
+              pointHoverRadius: 6,
+              pointHoverBackgroundColor: '#3b82f6',
+              pointHoverBorderColor: '#ffffff',
+              pointHoverBorderWidth: 2
+            },
+            {
+              label: '销量',
+              data: qty,
+              borderColor: '#64748b',
+              backgroundColor: 'rgba(100,116,139,0.15)',
+              lineTension: 0.25,
+              pointRadius: 0,
+              pointHoverRadius: 6,
+              pointHoverBackgroundColor: '#64748b',
+              pointHoverBorderColor: '#ffffff',
+              pointHoverBorderWidth: 2
+            }
+          ]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          tooltips: {
+            enabled: true,
+            backgroundColor: 'rgba(0,0,0,0.8)',
+            titleFontColor: '#ffffff',
+            bodyFontColor: '#ffffff',
+            borderColor: '#374151',
+            borderWidth: 1,
+            cornerRadius: 6,
+            displayColors: true,
+            callbacks: {
+              title(context) {
+                return `日期: ${context[0].label}`
+              },
+              label(context, data) {
+                const datasetLabel = data.datasets[context.datasetIndex].label
+                return `${datasetLabel}: ${Number(context.value).toLocaleString()}`
+              }
+            }
+          },
+          hover: {
+            mode: 'index',
+            intersect: false
+          },
+          scales: {
+            xAxes: [{
+              ticks: {
+                maxRotation: 0,
+                autoSkip: true,
+                maxTicksLimit: 12,
+                callback(value, index) {
+                  return labels[index]
+                }
+              }
+            }]
+          }
+        },
+        plugins: [{
+          id: 'stage-backgrounds',
+          beforeDatasetsDraw(chart) {
+            const ctx = chart.ctx
+            const chartArea = chart.chartArea
+            const xScale = chart.scales['x-axis-0']
+
+            if (!segments.length) return
+
+            ctx.save()
+            segments.forEach(segment => {
+              const startX = xScale.getPixelForValue(segment.start)
+              const endX = xScale.getPixelForValue(segment.end)
+              const color = stageColors[segment.stage] || 'rgba(0,0,0,0.04)'
+
+              ctx.fillStyle = color
+              ctx.fillRect(startX, chartArea.top, endX - startX, chartArea.bottom - chartArea.top)
+
+              ctx.fillStyle = '#374151'
+              ctx.font = 'bold 12px sans-serif'
+              ctx.textAlign = 'left'
+              ctx.fillText(segment.stage, startX + 4, chartArea.top + 14)
+            })
+            ctx.restore()
+          }
+        }, {
+          id: 'stage-markers',
+          afterDatasetsDraw(chart) {
+            const ctx = chart.ctx
+            const chartArea = chart.chartArea
+            const xScale = chart.scales['x-axis-0']
+            const yScale = chart.scales['y-axis-0']
+            ctx.save()
+
+            boundaryIndices.forEach(boundary => {
+              const x = xScale.getPixelForValue(boundary.index)
+
+              ctx.strokeStyle = '#ef4444'
+              ctx.setLineDash([8, 4])
+              ctx.lineWidth = 2
+              ctx.beginPath()
+              ctx.moveTo(x, chartArea.top)
+              ctx.lineTo(x, chartArea.bottom)
+              ctx.stroke()
+              ctx.setLineDash([])
+
+              const labelText = boundary.type
+              const labelWidth = ctx.measureText(labelText).width + 8
+              const labelHeight = 16
+              const labelX = x - labelWidth / 2
+              const labelY = chartArea.top + 8
+
+              ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
+              ctx.fillRect(labelX, labelY, labelWidth, labelHeight)
+
+              ctx.strokeStyle = '#ef4444'
+              ctx.lineWidth = 1
+              ctx.strokeRect(labelX, labelY, labelWidth, labelHeight)
+
+              ctx.fillStyle = '#ef4444'
+              ctx.font = 'bold 11px sans-serif'
+              ctx.textAlign = 'center'
+              ctx.fillText(labelText, x, labelY + 12)
+            })
+
+            if (peakRevenueIndex >= 0 && peakRevenueIndex < revenue.length && revenue[peakRevenueIndex] != null) {
+              const x = xScale.getPixelForValue(peakRevenueIndex)
+              const y = yScale.getPixelForValue(revenue[peakRevenueIndex])
+
+              ctx.fillStyle = '#dc2626'
+              ctx.beginPath()
+              ctx.arc(x, y, 5, 0, Math.PI * 2)
+              ctx.fill()
+
+              ctx.strokeStyle = '#ffffff'
+              ctx.lineWidth = 2
+              ctx.stroke()
+
+              ctx.fillStyle = '#dc2626'
+              ctx.font = 'bold 12px sans-serif'
+              ctx.textAlign = 'left'
+              ctx.fillText('销售额峰值', x + 10, y - 10)
+
+              ctx.font = '11px sans-serif'
+              ctx.fillText(`¥${Number(revenue[peakRevenueIndex]).toLocaleString()}`, x + 10, y + 2)
+            }
+
+            if (peakQtyIndex >= 0 && peakQtyIndex < qty.length && qty[peakQtyIndex] != null) {
+              const x = xScale.getPixelForValue(peakQtyIndex)
+              const y = yScale.getPixelForValue(qty[peakQtyIndex])
+
+              ctx.fillStyle = '#2563eb'
+              ctx.beginPath()
+              ctx.arc(x, y, 5, 0, Math.PI * 2)
+              ctx.fill()
+
+              ctx.strokeStyle = '#ffffff'
+              ctx.lineWidth = 2
+              ctx.stroke()
+
+              ctx.fillStyle = '#2563eb'
+              ctx.font = 'bold 12px sans-serif'
+              ctx.textAlign = 'left'
+              ctx.fillText('销量峰值', x + 10, y - 10)
+
+              ctx.font = '11px sans-serif'
+              ctx.fillText(`${String(qty[peakQtyIndex]).toLocaleString()}件`, x + 10, y + 2)
+            }
+            ctx.restore()
+          }
+        }]
+      })
+    },
+    renderStageCompare() {
+      const canvas = this.$refs.stageCompareRef
+      if (!canvas || !this.detail) {
+        if (this.stageCompareChart) {
+          this.stageCompareChart.destroy()
+          this.stageCompareChart = null
+        }
+        return
+      }
+
+      const stats = this.displayStageStats || {}
+      const labels = stageOrder.filter(stage => stats[stage]).concat(Object.keys(stats).filter(stage => stageOrder.indexOf(stage) === -1))
+      const revenueData = labels.map(stage => (stats[stage] && stats[stage].totalRevenue) || 0)
+      const qtyData = labels.map(stage => (stats[stage] && stats[stage].totalQuantity) || 0)
+      const durationData = labels.map(stage => (stats[stage] && stats[stage].durationDays) || 0)
+
+      if (this.stageCompareChart) this.stageCompareChart.destroy()
+      this.stageCompareChart = new Chart(canvas, {
+        type: 'bar',
+        data: {
+          labels,
+          datasets: [
+            { label: '销售额', data: revenueData, backgroundColor: 'rgba(59,130,246,0.7)' },
+            { label: '销量', data: qtyData, backgroundColor: 'rgba(16,185,129,0.7)' },
+            { label: '持续天数', data: durationData, backgroundColor: 'rgba(245,158,11,0.7)' }
+          ]
+        },
+        options: {
+          responsive: true,
+          maintainAspectRatio: false,
+          scales: {
+            xAxes: [{ stacked: false }],
+            yAxes: [{ ticks: { beginAtZero: true } }]
+          }
+        }
+      })
+    },
+    formatUploadDate(date) {
+      const target = date instanceof Date ? date : new Date(date)
+      if (Number.isNaN(target.getTime())) return ''
+      const year = target.getFullYear()
+      const month = String(target.getMonth() + 1).padStart(2, '0')
+      const day = String(target.getDate()).padStart(2, '0')
+      return `${year}-${month}-${day}`
+    },
+    exportResults() {
+      if (!this.hasResults) {
+        this.$modal.msgError('暂无可导出的分析结果')
+        return
+      }
+      const payload = JSON.stringify(this.results || {}, null, 2)
+      const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
+      const url = URL.createObjectURL(blob)
+      const link = document.createElement('a')
+      link.href = url
+      link.download = `${this.currentConfig.exportPrefix}_${this.formatUploadDate(new Date())}.json`
+      document.body.appendChild(link)
+      link.click()
+      document.body.removeChild(link)
+      URL.revokeObjectURL(url)
+    },
+    pickFirstValue(results) {
+      const keys = Object.keys(results || {})
+      return keys.find(key => key !== '_analysis_summary_') || ''
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.app-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
+
+  h2 {
+    font-size: 24px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 8px;
+
+    i {
+      margin-right: 8px;
+      color: #409eff;
+    }
+  }
+
+  .page-desc {
+    color: #909399;
+    font-size: 14px;
+    margin: 0;
+  }
+}
+
+.analysis-switch {
+  display: inline-flex;
+  padding: 4px;
+  border-radius: 10px;
+  background: #f3f6fb;
+  border: 1px solid #dce6f2;
+}
+
+.switch-btn {
+  border: none;
+  background: transparent;
+  color: #606266;
+  padding: 8px 16px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.switch-btn.active {
+  background: #ffffff;
+  color: #2563eb;
+  box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
+}
+
+.mb-20 { margin-bottom: 20px; }
+
+.upload-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #ffffff;
+  border: 1px solid #e6eaf2;
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.toolbar-upload ::v-deep .el-upload {
+  display: inline-flex;
+}
+
+.toolbar-status {
+  font-size: 13px;
+  color: #16a34a;
+  background: #f0fdf4;
+  border: 1px solid #dcfce7;
+  border-radius: 6px;
+  padding: 6px 10px;
+}
+
+.toolbar-status.muted {
+  color: #6b7280;
+  background: #f8fafc;
+  border-color: #e2e8f0;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  min-height: 280px;
+  background: #ffffff;
+  border-radius: 8px;
+  border: 1px solid #e6eaf2;
+  color: #909399;
+  font-size: 14px;
+
+  i {
+    font-size: 40px;
+    color: #c0c4cc;
+  }
+
+  p {
+    margin: 0;
+  }
+}
+
+.p-6 { padding: 24px; }
+.p-4 { padding: 16px; }
+.px-3 { padding-left: 12px; padding-right: 12px; }
+.px-4 { padding-left: 16px; padding-right: 16px; }
+.py-1 { padding-top: 4px; padding-bottom: 4px; }
+.py-2 { padding-top: 8px; padding-bottom: 8px; }
+.py-3 { padding-top: 12px; padding-bottom: 12px; }
+.pt-2 { padding-top: 8px; }
+.mb-2 { margin-bottom: 8px; }
+.mb-4 { margin-bottom: 16px; }
+.mb-6 { margin-bottom: 24px; }
+.mb-8 { margin-bottom: 32px; }
+.mt-1 { margin-top: 4px; }
+.mt-4 { margin-top: 16px; }
+.mt-8 { margin-top: 32px; }
+.ml-2 { margin-left: 8px; }
+.mr-1 { margin-right: 4px; }
+
+.flex { display: flex; }
+.flex-wrap { flex-wrap: wrap; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: 8px; }
+.gap-3 { gap: 12px; }
+.gap-4 { gap: 16px; }
+.gap-8 { gap: 20px; }
+
+.grid { display: grid; }
+.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.lg\:col-span-2 { grid-column: span 2 / span 2; }
+.overflow-x-auto { overflow-x: auto; }
+.min-w-full { min-width: 100%; }
+
+.text-base { font-size: 16px; line-height: 1.5; }
+.text-lg { font-size: 18px; line-height: 1.5; }
+.text-sm { font-size: 14px; line-height: 1.5; }
+.text-xs { font-size: 12px; line-height: 1.4; }
+.font-semibold { font-weight: 600; }
+.font-medium { font-weight: 500; }
+.text-left { text-align: left; }
+.uppercase { text-transform: uppercase; }
+.tracking-wide { letter-spacing: 0.04em; }
+.tracking-wider { letter-spacing: 0.06em; }
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.text-gray-800 { color: #303133; }
+.text-gray-700 { color: #606266; }
+.text-gray-600 { color: #909399; }
+.text-gray-500 { color: #909399; }
+.text-gray-400 { color: #9ca3af; }
+.text-blue-600 { color: #2563eb; }
+.text-green-700 { color: #15803d; }
+.text-green-600 { color: #16a34a; }
+.text-red-600 { color: #dc2626; }
+.text-orange-600 { color: #ea580c; }
+.text-yellow-800 { color: #92400e; }
+
+.bg-white { background: #ffffff; }
+.bg-gray-50 { background: #f9fafb; }
+.bg-gray-500 { background: #6b7280; }
+.bg-blue-500 { background: #3b82f6; }
+.bg-green-100 { background: #dcfce7; }
+.bg-red-100 { background: #fee2e2; }
+
+.bg-gradient-to-r { background-image: linear-gradient(to right, var(--from-color), var(--to-color)); }
+.from-yellow-100 { --from-color: #fef9c3; }
+.to-amber-100 { --to-color: #fef3c7; }
+
+.border { border-width: 1px; border-style: solid; }
+.border-gray-200 { border-color: #e5e7eb; }
+.border-gray-300 { border-color: #d1d5db; }
+.rounded-xl { border-radius: 8px; }
+.rounded-lg { border-radius: 10px; }
+.rounded-full { border-radius: 9999px; }
+.shadow-sm { border: 1px solid #e6eaf2; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }
+
+.h-96 { height: 400px; }
+.h-64 { height: 256px; }
+.w-3 { width: 12px; }
+.h-3 { height: 12px; }
+
+.space-y-2 > * + * { margin-top: 8px; }
+.space-y-6 > * + * { margin-top: 24px; }
+.divide-y > * + * { border-top: 1px solid #e5e7eb; }
+.divide-gray-200 > * + * { border-top-color: #e5e7eb; }
+
+.focus\:outline-none:focus { outline: none; }
+.focus\:border-transparent:focus { border-color: transparent; }
+.focus\:ring-2:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); }
+.focus\:ring-blue-500:focus { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45); }
+
+.entity-select {
+  min-width: 220px;
+  background: #ffffff;
+}
+
+table {
+  border-collapse: separate;
+  border-spacing: 0;
+}
+
+thead tr {
+  background: #f8fafc;
+}
+
+thead th:first-child {
+  border-top-left-radius: 8px;
+}
+
+thead th:last-child {
+  border-top-right-radius: 8px;
+}
+
+@media (max-width: 767px) {
+  .page-header,
+  .upload-toolbar {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .analysis-switch,
+  .toolbar-left {
+    width: 100%;
+  }
+
+  .switch-btn {
+    flex: 1;
+  }
+}
+
+@media (min-width: 768px) {
+  .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (min-width: 1024px) {
+  .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+
+.progress-bar { height: 8px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
+.progress-value { height: 100%; border-radius: 999px; }
+
+.lifecycle-stage { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
+.lifecycle-stage.intro { background: #dbeafe; color: #1e40af; }
+.lifecycle-stage.growth { background: #dcfce7; color: #166534; }
+.lifecycle-stage.maturity { background: #fef3c7; color: #92400e; }
+.lifecycle-stage.decline { background: #fee2e2; color: #991b1b; }
+</style>

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

@@ -1,1119 +0,0 @@
-<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 { analyzeFile, getResults } from '@/api/client'
-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_PYTHON_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() {
-      getResults().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 isLt300M = file.size / 1024 / 1024 < 300
-
-      if (!isExcel) {
-        this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
-        return false
-      }
-      if (!isLt300M) {
-        this.$modal.msgError('上传文件大小不能超过 300MB!')
-        return false
-      }
-      return true
-    },
-    handleFileUploadProgress() {
-      this.upload.isUploading = true
-    },
-    customUpload(options) {
-      const file = options.file
-      this.upload.isUploading = true
-      analyzeFile(file).then(response => {
-        this.upload.isUploading = false
-        if (response && response.success) {
-          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>

+ 1229 - 0
src/views/upload/index.vue

@@ -0,0 +1,1229 @@
+<template>
+  <div class="app-container upload-page">
+    <div class="page-header">
+      <h2><i class="el-icon-upload2"></i> 数据上传</h2>
+    </div>
+
+    <el-row :gutter="20" class="metrics-row">
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="metric-card">
+          <div class="metric-content">
+            <div class="metric-icon icon-blue">
+              <i class="el-icon-collection-tag"></i>
+            </div>
+            <div class="metric-info">
+              <div class="metric-label">数据表总数</div>
+              <div class="metric-value">{{ uploadTables.length }}</div>
+              <div class="metric-sub">按文档固定顺序上传</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="metric-card">
+          <div class="metric-content">
+            <div class="metric-icon icon-green">
+              <i class="el-icon-circle-check"></i>
+            </div>
+            <div class="metric-info">
+              <div class="metric-label">已完成上传</div>
+              <div class="metric-value">{{ completedTableCount }}</div>
+              <div class="metric-sub">{{ progressPercent }}% 流程进度</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="metric-card">
+          <div class="metric-content">
+            <div class="metric-icon icon-orange">
+              <i class="el-icon-document-copy"></i>
+            </div>
+            <div class="metric-info">
+              <div class="metric-label">当前样例数据量</div>
+              <div class="metric-value">{{ currentRecordTotal }}</div>
+              <div class="metric-sub">来自各表预览快照</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :lg="6">
+        <el-card class="metric-card">
+          <div class="metric-content">
+            <div class="metric-icon icon-red">
+              <i class="el-icon-right"></i>
+            </div>
+            <div class="metric-info">
+              <div class="metric-label">下一待上传</div>
+              <div class="metric-value next-step">{{ nextPendingLabel }}</div>
+              <div class="metric-sub">严格按顺序解锁</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card class="overview-card">
+      <div class="overview-top">
+        <div>
+          <div class="overview-title">上传规则</div>
+          <div class="overview-desc">
+            当前页面仅实现前端交互。文件选择、顺序校验、状态流转和数据预览均为本地演示,后端接口后续接入。
+          </div>
+        </div>
+        <div class="overview-actions">
+          <el-button type="primary" size="small" @click="focusNextPending">
+            <i class="el-icon-position"></i> 定位到下一步
+          </el-button>
+          <el-button size="small" @click="resetAllProgress">
+            <i class="el-icon-refresh-left"></i> 重置进度
+          </el-button>
+        </div>
+      </div>
+      <div class="overview-progress">
+        <span>整体上传进度</span>
+        <el-progress :percentage="progressPercent" :stroke-width="10" />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="content-row">
+      <el-col :xs="24" :lg="10">
+        <el-card class="order-card">
+          <template slot="header">
+            <div class="card-header">
+              <span>上传顺序面板</span>
+              <el-tag size="mini" type="info">共 {{ uploadTables.length }} 步</el-tag>
+            </div>
+          </template>
+
+          <div class="order-list">
+            <div
+              v-for="table in orderedTables"
+              :key="table.key"
+              class="order-item"
+              :class="{
+                active: activeTableKey === table.key,
+                uploaded: table.uploaded,
+                locked: !canUpload(table) && !table.uploaded
+              }"
+              @click="selectTable(table.key)"
+            >
+              <div class="order-index">{{ table.order }}</div>
+              <div class="order-main">
+                <div class="order-title-row">
+                  <div>
+                    <div class="order-title">{{ table.name }}</div>
+                    <div class="order-table">{{ table.tableName }}</div>
+                  </div>
+                  <el-tag :type="getStatusType(table)" size="mini">
+                    {{ getStatusText(table) }}
+                  </el-tag>
+                </div>
+                <div class="order-desc">{{ table.description }}</div>
+                <div class="order-tags">
+                  <el-tag
+                    v-if="table.dependencies.length === 0"
+                    size="mini"
+                    effect="plain"
+                    type="success"
+                  >
+                    无外键依赖
+                  </el-tag>
+                  <el-tag
+                    v-for="dependency in table.dependencies"
+                    :key="dependency"
+                    size="mini"
+                    effect="plain"
+                  >
+                    {{ dependency }}
+                  </el-tag>
+                </div>
+                <div class="order-footer">
+                  <span>当前数据 {{ table.recordCount }} 条</span>
+                  <span v-if="table.fileName">已选文件:{{ table.fileName }}</span>
+                  <span v-else-if="!table.uploaded && !canUpload(table)">{{ getBlockedReason(table) }}</span>
+                  <span v-else>待选择上传文件</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+
+      <el-col :xs="24" :lg="14">
+        <el-card class="detail-card">
+          <template slot="header">
+            <div class="card-header">
+              <span>当前表详情</span>
+              <el-tag size="mini">{{ selectedTable.tableName }}</el-tag>
+            </div>
+          </template>
+
+          <div class="detail-actions">
+            <el-button
+              type="primary"
+              size="small"
+              :disabled="selectedTable.uploaded"
+              @click="triggerFileSelect(selectedTable)"
+            >
+              <i class="el-icon-folder-opened"></i> 选择文件
+            </el-button>
+            <el-button
+              type="success"
+              size="small"
+              :disabled="selectedTable.uploaded || !canUpload(selectedTable)"
+              @click="submitTable(selectedTable)"
+            >
+              <i class="el-icon-upload"></i> 模拟上传
+            </el-button>
+            <el-button
+              size="small"
+              :disabled="!selectedTable.fileName && !selectedTable.uploaded"
+              @click="resetFromTable(selectedTable)"
+            >
+              <i class="el-icon-delete"></i> 清空当前及后续
+            </el-button>
+          </div>
+
+          <el-alert
+            v-if="!canUpload(selectedTable) && !selectedTable.uploaded"
+            :closable="false"
+            type="warning"
+            show-icon
+            :title="getBlockedReason(selectedTable)"
+            class="detail-alert"
+          />
+
+          <el-alert
+            v-else
+            :closable="false"
+            type="info"
+            show-icon
+            class="detail-alert"
+            :title="selectedTable.uploaded ? '当前步骤已完成,可继续下一张表。' : '当前步骤已解锁,可选择文件后提交。'"
+          />
+
+          <el-descriptions :column="2" border class="table-descriptions">
+            <el-descriptions-item label="中文名称">{{ selectedTable.name }}</el-descriptions-item>
+            <el-descriptions-item label="上传顺序">第 {{ selectedTable.order }} 步</el-descriptions-item>
+            <el-descriptions-item label="依赖关系">{{ formatDependencies(selectedTable) }}</el-descriptions-item>
+            <el-descriptions-item label="当前状态">{{ getStatusText(selectedTable) }}</el-descriptions-item>
+            <el-descriptions-item label="当前数据量">{{ selectedTable.recordCount }} 条</el-descriptions-item>
+            <el-descriptions-item label="最近操作">
+              {{ selectedTable.updatedAt || '暂无操作' }}
+            </el-descriptions-item>
+          </el-descriptions>
+
+          <div class="selected-file" v-if="selectedTable.fileName">
+            <i class="el-icon-document"></i>
+            <span>{{ selectedTable.fileName }}</span>
+            <span class="file-size">{{ selectedTable.fileSize }}</span>
+          </div>
+
+          <div class="section-title">
+            <span>字段摘要</span>
+            <el-tag size="mini" type="info">{{ selectedTable.fields.length }} 个核心字段</el-tag>
+          </div>
+          <el-table :data="selectedTable.fields" stripe size="small" class="data-table">
+            <el-table-column prop="name" label="字段名" min-width="140" />
+            <el-table-column prop="type" label="类型" min-width="120" />
+            <el-table-column prop="constraint" label="约束" min-width="160" />
+            <el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
+          </el-table>
+
+          <div class="section-title section-with-gap">
+            <span>当前表部分数据</span>
+            <el-tag size="mini" type="success">{{ selectedTable.recordCount }} 条</el-tag>
+          </div>
+          <el-table :data="selectedTable.sampleRows" stripe size="small" class="data-table">
+            <el-table-column
+              v-for="column in selectedTable.previewColumns"
+              :key="column.prop"
+              :prop="column.prop"
+              :label="column.label"
+              :min-width="column.minWidth || 120"
+              show-overflow-tooltip
+            />
+          </el-table>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card class="snapshot-card">
+      <template slot="header">
+        <div class="card-header">
+          <span>当前各数据表部分数据</span>
+          <el-tag size="mini" type="info">可切换查看 15 张表</el-tag>
+        </div>
+      </template>
+
+      <el-tabs v-model="activePreviewTab" @tab-click="handlePreviewTabChange">
+        <el-tab-pane
+          v-for="table in orderedTables"
+          :key="table.key"
+          :name="table.key"
+        >
+          <span slot="label">{{ table.order }}. {{ table.name }}</span>
+          <div class="snapshot-meta">
+            <el-tag size="mini" :type="getStatusType(table)">{{ getStatusText(table) }}</el-tag>
+            <el-tag size="mini" effect="plain" type="info">{{ table.tableName }}</el-tag>
+            <el-tag size="mini" effect="plain" type="success">当前数据 {{ table.recordCount }} 条</el-tag>
+            <span class="snapshot-desc">{{ table.description }}</span>
+          </div>
+          <el-table :data="table.sampleRows" stripe size="mini">
+            <el-table-column
+              v-for="column in table.previewColumns"
+              :key="column.prop"
+              :prop="column.prop"
+              :label="column.label"
+              :min-width="column.minWidth || 120"
+              show-overflow-tooltip
+            />
+          </el-table>
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+
+    <div class="hidden-inputs">
+      <input
+        v-for="table in orderedTables"
+        :key="table.key"
+        :ref="'fileInput-' + table.key"
+        type="file"
+        accept=".xlsx,.xls,.csv"
+        @change="handleFileChange(table, $event)"
+      >
+    </div>
+  </div>
+</template>
+
+<script>
+const UPLOAD_TABLES = [
+  {
+    key: 'warehouse',
+    order: 1,
+    name: '仓库表',
+    tableName: 'warehouse',
+    description: '仓库主数据,无外键依赖。',
+    dependencies: [],
+    recordCount: 12,
+    fields: [
+      { name: 'warehouse_code', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '仓库编码(主键)' },
+      { name: 'warehouse_name', type: 'VARCHAR(100)', constraint: 'NOT NULL', description: '仓库名称' },
+      { name: 'warehouse_type', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '仓库类型(普通仓 / 冷链仓 / 电商仓等)' },
+      { name: 'warehouse_address', type: 'VARCHAR(200)', constraint: 'NOT NULL', description: '仓库地址' }
+    ],
+    previewColumns: [
+      { prop: 'warehouse_code', label: '仓库编码', minWidth: 120 },
+      { prop: 'warehouse_name', label: '仓库名称', minWidth: 160 },
+      { prop: 'warehouse_type', label: '仓库类型', minWidth: 120 },
+      { prop: 'warehouse_address', label: '仓库地址', minWidth: 220 }
+    ],
+    sampleRows: [
+      { warehouse_code: 'WH-SH-01', warehouse_name: '上海中心仓', warehouse_type: '电商仓', warehouse_address: '上海市嘉定区叶城路 88 号' },
+      { warehouse_code: 'WH-HZ-02', warehouse_name: '华中周转仓', warehouse_type: '普通仓', warehouse_address: '武汉市东西湖区高桥五路 26 号' },
+      { warehouse_code: 'WH-GZ-03', warehouse_name: '广州冷链仓', warehouse_type: '冷链仓', warehouse_address: '广州市黄埔区开源大道 16 号' }
+    ]
+  },
+  {
+    key: 'supplier',
+    order: 2,
+    name: '供应商表',
+    tableName: 'supplier',
+    description: '供应商主数据,无外键依赖。',
+    dependencies: [],
+    recordCount: 18,
+    fields: [
+      { name: 'supplier_id', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '供应商编号(主键)' },
+      { name: 'supplier_name', type: 'VARCHAR(100)', constraint: 'NOT NULL', description: '供应商名称' }
+    ],
+    previewColumns: [
+      { prop: 'supplier_id', label: '供应商编号', minWidth: 140 },
+      { prop: 'supplier_name', label: '供应商名称', minWidth: 220 }
+    ],
+    sampleRows: [
+      { supplier_id: 'SUP-001', supplier_name: '华东包装材料有限公司' },
+      { supplier_id: 'SUP-007', supplier_name: '宁波晨光辅料工厂' },
+      { supplier_id: 'SUP-015', supplier_name: '青岛智造电子配件厂' }
+    ]
+  },
+  {
+    key: 'semi_finished_product',
+    order: 3,
+    name: '半成品表',
+    tableName: 'semi_finished_product',
+    description: '半成品基础档案,无外键依赖。',
+    dependencies: [],
+    recordCount: 26,
+    fields: [
+      { name: 'sku', type: 'VARCHAR(64)', constraint: 'PRIMARY KEY', description: '半成品 SKU(主键)' },
+      { name: 'semi_name', type: 'VARCHAR(100)', constraint: 'NOT NULL', description: '半成品名称' },
+      { name: 'price', type: 'DECIMAL(10,2)', constraint: 'NOT NULL', description: '半成品单价' }
+    ],
+    previewColumns: [
+      { prop: 'sku', label: '半成品 SKU', minWidth: 160 },
+      { prop: 'semi_name', label: '半成品名称', minWidth: 220 },
+      { prop: 'price', label: '单价', minWidth: 100 }
+    ],
+    sampleRows: [
+      { sku: 'SEMI-AX1001', semi_name: '主控板组件 A', price: '38.50' },
+      { sku: 'SEMI-BX2040', semi_name: '电源模块 B', price: '21.00' },
+      { sku: 'SEMI-CX3012', semi_name: '外壳组件 C', price: '12.80' }
+    ]
+  },
+  {
+    key: 'product',
+    order: 4,
+    name: '产品表',
+    tableName: 'product',
+    description: '成品主数据,无外键依赖。',
+    dependencies: [],
+    recordCount: 48,
+    fields: [
+      { name: 'sku', type: 'VARCHAR(64)', constraint: 'PRIMARY KEY', description: '产品 SKU(主键)' },
+      { name: 'spu', type: 'VARCHAR(64)', constraint: 'NOT NULL', description: '标准产品单元 SPU' },
+      { name: 'product_code', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '产品代码' },
+      { name: 'product_name', type: 'VARCHAR(128)', constraint: 'NOT NULL', description: '产品名称' }
+    ],
+    previewColumns: [
+      { prop: 'sku', label: 'SKU', minWidth: 160 },
+      { prop: 'spu', label: 'SPU', minWidth: 120 },
+      { prop: 'product_code', label: '产品代码', minWidth: 140 },
+      { prop: 'product_name', label: '产品名称', minWidth: 220 }
+    ],
+    sampleRows: [
+      { sku: 'SKU-P1001-BLK', spu: 'SPU-P1001', product_code: 'PD-1001', product_name: '智能水杯 黑色款' },
+      { sku: 'SKU-P1001-WHT', spu: 'SPU-P1001', product_code: 'PD-1002', product_name: '智能水杯 白色款' },
+      { sku: 'SKU-P2100-GRY', spu: 'SPU-P2100', product_code: 'PD-2100', product_name: '便携保温壶 灰色款' }
+    ]
+  },
+  {
+    key: 'product_structure',
+    order: 5,
+    name: '产品结构表',
+    tableName: 'product_structure',
+    description: '产品层级结构,自关联表,需先插入顶层节点。',
+    dependencies: [],
+    recordCount: 31,
+    fields: [
+      { name: 'level_no', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '当前层级编号(如 1、1-1、1-1-1)' },
+      { name: 'parent_id', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '父类 ID(关联本表)' },
+      { name: 'parent_name', type: 'VARCHAR(100)', constraint: 'NOT NULL', description: '父类分类名称' },
+      { name: 'category_id', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '分类 ID' }
+    ],
+    previewColumns: [
+      { prop: 'level_no', label: '层级编号', minWidth: 120 },
+      { prop: 'parent_id', label: '父级 ID', minWidth: 120 },
+      { prop: 'parent_name', label: '父级名称', minWidth: 140 },
+      { prop: 'category_id', label: '分类 ID', minWidth: 120 },
+      { prop: 'category_name', label: '分类名称', minWidth: 160 }
+    ],
+    sampleRows: [
+      { level_no: '1', parent_id: '-', parent_name: '根节点', category_id: 'CAT-ROOT', category_name: '产品目录' },
+      { level_no: '1-1', parent_id: '1', parent_name: '产品目录', category_id: 'CAT-SMART', category_name: '智能杯具' },
+      { level_no: '1-1-1', parent_id: '1-1', parent_name: '智能杯具', category_id: 'CAT-TRAVEL', category_name: '便携系列' }
+    ]
+  },
+  {
+    key: 'store',
+    order: 6,
+    name: '店铺表',
+    tableName: 'store',
+    description: '店铺主数据,无外键依赖。',
+    dependencies: [],
+    recordCount: 9,
+    fields: [
+      { name: 'store_code', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '店铺编码(主键)' },
+      { name: 'store_name', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '店铺名称' },
+      { name: 'dept_name', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '事业部名称' },
+      { name: 'channel_name', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '渠道名称' }
+    ],
+    previewColumns: [
+      { prop: 'store_code', label: '店铺编码', minWidth: 120 },
+      { prop: 'store_name', label: '店铺名称', minWidth: 180 },
+      { prop: 'dept_name', label: '事业部', minWidth: 120 },
+      { prop: 'channel_name', label: '渠道', minWidth: 120 }
+    ],
+    sampleRows: [
+      { store_code: 'ST-TM-01', store_name: '天猫旗舰店', dept_name: '电商事业部', channel_name: '天猫' },
+      { store_code: 'ST-JD-01', store_name: '京东自营店', dept_name: '电商事业部', channel_name: '京东' },
+      { store_code: 'ST-DY-01', store_name: '抖音直播店', dept_name: '新零售事业部', channel_name: '抖音' }
+    ]
+  },
+  {
+    key: 'structure_rule_setting',
+    order: 7,
+    name: '结构规则设置表',
+    tableName: 'structure_rule_setting',
+    description: '结构编码规则配置,无外键依赖。',
+    dependencies: [],
+    recordCount: 6,
+    fields: [
+      { name: 'rule_id', type: 'BIGINT', constraint: 'PRIMARY KEY AUTO_INCREMENT', description: '规则 ID(主键)' },
+      { name: 'rule_type', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '规则对象:warehouse_location / product_structure' },
+      { name: 'parent_field', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '父编码字段(如 warehouse_code、parent_id)' },
+      { name: 'parent_length', type: 'INT', constraint: 'NOT NULL', description: '父编码占用位数' },
+      { name: 'current_level', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '当前层级名称(L1/L2/L3)' }
+    ],
+    previewColumns: [
+      { prop: 'rule_type', label: '规则对象', minWidth: 180 },
+      { prop: 'parent_field', label: '父字段', minWidth: 140 },
+      { prop: 'parent_length', label: '父编码长度', minWidth: 110 },
+      { prop: 'current_level', label: '层级', minWidth: 100 },
+      { prop: 'current_length', label: '当前长度', minWidth: 100 }
+    ],
+    sampleRows: [
+      { rule_type: 'warehouse_location', parent_field: 'warehouse_code', parent_length: 8, current_level: 'L1', current_length: 4 },
+      { rule_type: 'warehouse_location', parent_field: 'warehouse_code', parent_length: 8, current_level: 'L2', current_length: 2 },
+      { rule_type: 'product_structure', parent_field: 'parent_id', parent_length: 6, current_level: 'L3', current_length: 3 }
+    ]
+  },
+  {
+    key: 'location',
+    order: 8,
+    name: '库位表',
+    tableName: 'location',
+    description: '库位主数据,依赖 warehouse。',
+    dependencies: ['warehouse'],
+    recordCount: 64,
+    fields: [
+      { name: 'location_code', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '库位编码(主键)' },
+      { name: 'warehouse_code', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '仓库编码(关联仓库表)' },
+      { name: 'location_type', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '库位类型(常温 / 冷藏 / 拣选区等)' },
+      { name: 'capacity_limit', type: 'INT', constraint: 'NOT NULL', description: '库位容量限制' }
+    ],
+    previewColumns: [
+      { prop: 'location_code', label: '库位编码', minWidth: 140 },
+      { prop: 'warehouse_code', label: '仓库编码', minWidth: 120 },
+      { prop: 'location_type', label: '库位类型', minWidth: 120 },
+      { prop: 'capacity_limit', label: '容量上限', minWidth: 100 }
+    ],
+    sampleRows: [
+      { location_code: 'WH-SH-01-A01', warehouse_code: 'WH-SH-01', location_type: '拣选区', capacity_limit: 1200 },
+      { location_code: 'WH-SH-01-B12', warehouse_code: 'WH-SH-01', location_type: '常温区', capacity_limit: 2400 },
+      { location_code: 'WH-GZ-03-C02', warehouse_code: 'WH-GZ-03', location_type: '冷藏区', capacity_limit: 800 }
+    ]
+  },
+  {
+    key: 'bom_list',
+    order: 9,
+    name: 'BOM 清单表',
+    tableName: 'bom_list',
+    description: '成品与半成品映射关系,依赖 product 和 semi_finished_product。',
+    dependencies: ['product', 'semi_finished_product'],
+    recordCount: 86,
+    fields: [
+      { name: 'id', type: 'BIGINT', constraint: 'PRIMARY KEY', description: '主键 ID(自增)' },
+      { name: 'finished_sku', type: 'VARCHAR(64)', constraint: 'FOREIGN KEY', description: '成品 SKU(关联产品表)' },
+      { name: 'semi_sku', type: 'VARCHAR(64)', constraint: 'FOREIGN KEY', description: '半成品 SKU(关联半成品表)' },
+      { name: 'quantity', type: 'INT', constraint: 'NOT NULL', description: '1 个成品所需该半成品的数量' }
+    ],
+    previewColumns: [
+      { prop: 'finished_sku', label: '成品 SKU', minWidth: 160 },
+      { prop: 'semi_sku', label: '半成品 SKU', minWidth: 160 },
+      { prop: 'quantity', label: '用量', minWidth: 100 },
+      { prop: 'remark', label: '说明', minWidth: 180 }
+    ],
+    sampleRows: [
+      { finished_sku: 'SKU-P1001-BLK', semi_sku: 'SEMI-AX1001', quantity: 1, remark: '主控板组件' },
+      { finished_sku: 'SKU-P1001-BLK', semi_sku: 'SEMI-BX2040', quantity: 1, remark: '电源模块' },
+      { finished_sku: 'SKU-P2100-GRY', semi_sku: 'SEMI-CX3012', quantity: 2, remark: '外壳组件' }
+    ]
+  },
+  {
+    key: 'purchase_receipt',
+    order: 10,
+    name: '采购入库单表',
+    tableName: 'purchase_receipt',
+    description: '采购入库业务数据,依赖 product 和 supplier。',
+    dependencies: ['product', 'supplier'],
+    recordCount: 124,
+    fields: [
+      { name: 'receipt_id', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '单据编号(主键)' },
+      { name: 'product_code', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '产品代码(关联产品表)' },
+      { name: 'supplier_id', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '供应商编号(关联供应商表)' },
+      { name: 'quantity', type: 'INT', constraint: 'NOT NULL', description: '采购入库数量' }
+    ],
+    previewColumns: [
+      { prop: 'receipt_id', label: '入库单号', minWidth: 140 },
+      { prop: 'product_code', label: '产品代码', minWidth: 120 },
+      { prop: 'supplier_id', label: '供应商编号', minWidth: 120 },
+      { prop: 'quantity', label: '入库数量', minWidth: 100 },
+      { prop: 'receipt_date', label: '入库日期', minWidth: 120 }
+    ],
+    sampleRows: [
+      { receipt_id: 'PR-20260401-001', product_code: 'PD-1001', supplier_id: 'SUP-001', quantity: 500, receipt_date: '2026-04-01' },
+      { receipt_id: 'PR-20260403-008', product_code: 'PD-2100', supplier_id: 'SUP-007', quantity: 320, receipt_date: '2026-04-03' },
+      { receipt_id: 'PR-20260408-016', product_code: 'PD-1002', supplier_id: 'SUP-015', quantity: 260, receipt_date: '2026-04-08' }
+    ]
+  },
+  {
+    key: 'order_main',
+    order: 11,
+    name: '订单表',
+    tableName: 'order_main',
+    description: '订单业务数据,依赖 product。',
+    dependencies: ['product'],
+    recordCount: 388,
+    fields: [
+      { name: 'order_id', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '订单编号(主键)' },
+      { name: 'order_date', type: 'DATETIME', constraint: 'NOT NULL', description: '下单时间' },
+      { name: 'product_code', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '产品代码(关联产品表)' },
+      { name: 'quantity', type: 'INT', constraint: 'NOT NULL', description: '数量' }
+    ],
+    previewColumns: [
+      { prop: 'order_id', label: '订单编号', minWidth: 160 },
+      { prop: 'order_date', label: '下单时间', minWidth: 160 },
+      { prop: 'product_code', label: '产品代码', minWidth: 120 },
+      { prop: 'quantity', label: '数量', minWidth: 90 },
+      { prop: 'store_code', label: '店铺编码', minWidth: 120 }
+    ],
+    sampleRows: [
+      { order_id: 'SO-20260410-0001', order_date: '2026-04-10 09:15:00', product_code: 'PD-1001', quantity: 2, store_code: 'ST-TM-01' },
+      { order_id: 'SO-20260410-0008', order_date: '2026-04-10 10:48:00', product_code: 'PD-1002', quantity: 1, store_code: 'ST-JD-01' },
+      { order_id: 'SO-20260410-0013', order_date: '2026-04-10 11:26:00', product_code: 'PD-2100', quantity: 3, store_code: 'ST-DY-01' }
+    ]
+  },
+  {
+    key: 'assembly_record',
+    order: 12,
+    name: '组装记录表',
+    tableName: 'assembly_record',
+    description: '组装业务记录,依赖 product。',
+    dependencies: ['product'],
+    recordCount: 53,
+    fields: [
+      { name: 'id', type: 'BIGINT', constraint: 'PRIMARY KEY', description: '主键 ID(自增)' },
+      { name: 'product_code', type: 'VARCHAR(50)', constraint: 'FOREIGN KEY', description: '产品代码(关联产品表)' },
+      { name: 'assembly_date', type: 'DATE', constraint: 'NOT NULL', description: '组装日期' },
+      { name: 'quantity', type: 'INT', constraint: 'NOT NULL', description: '组装数量' }
+    ],
+    previewColumns: [
+      { prop: 'id', label: '记录 ID', minWidth: 100 },
+      { prop: 'product_code', label: '产品代码', minWidth: 120 },
+      { prop: 'assembly_date', label: '组装日期', minWidth: 120 },
+      { prop: 'quantity', label: '组装数量', minWidth: 100 },
+      { prop: 'operator', label: '操作人', minWidth: 120 }
+    ],
+    sampleRows: [
+      { id: 9001, product_code: 'PD-1001', assembly_date: '2026-04-09', quantity: 180, operator: '张磊' },
+      { id: 9008, product_code: 'PD-1002', assembly_date: '2026-04-10', quantity: 120, operator: '陈辰' },
+      { id: 9015, product_code: 'PD-2100', assembly_date: '2026-04-11', quantity: 96, operator: '王瑶' }
+    ]
+  },
+  {
+    key: 'outbound_order',
+    order: 13,
+    name: '出库单表',
+    tableName: 'outbound_order',
+    description: '出库业务数据,依赖 location 和 product。',
+    dependencies: ['location', 'product'],
+    recordCount: 117,
+    fields: [
+      { name: 'order_no', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY', description: '出库单据编号(主键)' },
+      { name: 'location_code', type: 'VARCHAR(32)', constraint: 'FOREIGN KEY', description: '库位编码(关联库位表)' },
+      { name: 'sku', type: 'VARCHAR(64)', constraint: 'FOREIGN KEY', description: '产品 SKU(关联产品表)' },
+      { name: 'outbound_date', type: 'DATE', constraint: 'NOT NULL', description: '出库日期' }
+    ],
+    previewColumns: [
+      { prop: 'order_no', label: '出库单号', minWidth: 150 },
+      { prop: 'location_code', label: '库位编码', minWidth: 140 },
+      { prop: 'sku', label: 'SKU', minWidth: 160 },
+      { prop: 'outbound_date', label: '出库日期', minWidth: 120 },
+      { prop: 'quantity', label: '数量', minWidth: 90 }
+    ],
+    sampleRows: [
+      { order_no: 'OB-20260412-001', location_code: 'WH-SH-01-A01', sku: 'SKU-P1001-BLK', outbound_date: '2026-04-12', quantity: 42 },
+      { order_no: 'OB-20260412-003', location_code: 'WH-SH-01-B12', sku: 'SKU-P1001-WHT', outbound_date: '2026-04-12', quantity: 31 },
+      { order_no: 'OB-20260413-009', location_code: 'WH-GZ-03-C02', sku: 'SKU-P2100-GRY', outbound_date: '2026-04-13', quantity: 18 }
+    ]
+  },
+  {
+    key: 'inventory_detail',
+    order: 14,
+    name: '库存明细表',
+    tableName: 'inventory_detail',
+    description: '库存明细数据,依赖 location 和 product。',
+    dependencies: ['location', 'product'],
+    recordCount: 142,
+    fields: [
+      { name: 'location_code', type: 'VARCHAR(32)', constraint: 'PRIMARY KEY, FOREIGN KEY', description: '库位编码(关联库位表)' },
+      { name: 'sku', type: 'VARCHAR(64)', constraint: 'PRIMARY KEY, FOREIGN KEY', description: '产品 SKU(关联产品表)' },
+      { name: 'stock_quantity', type: 'INT', constraint: 'NOT NULL DEFAULT 0', description: '可用库存数量' },
+      { name: 'lock_quantity', type: 'INT', constraint: 'NOT NULL DEFAULT 0', description: '锁定库存数量' }
+    ],
+    previewColumns: [
+      { prop: 'location_code', label: '库位编码', minWidth: 140 },
+      { prop: 'sku', label: 'SKU', minWidth: 160 },
+      { prop: 'stock_quantity', label: '可用库存', minWidth: 100 },
+      { prop: 'lock_quantity', label: '锁定库存', minWidth: 100 }
+    ],
+    sampleRows: [
+      { location_code: 'WH-SH-01-A01', sku: 'SKU-P1001-BLK', stock_quantity: 860, lock_quantity: 28 },
+      { location_code: 'WH-SH-01-B12', sku: 'SKU-P1001-WHT', stock_quantity: 640, lock_quantity: 16 },
+      { location_code: 'WH-GZ-03-C02', sku: 'SKU-P2100-GRY', stock_quantity: 218, lock_quantity: 12 }
+    ]
+  },
+  {
+    key: 'metrics',
+    order: 15,
+    name: '指标表',
+    tableName: 'metrics',
+    description: '业务指标聚合结果,依赖仓库、供应商、店铺、订单等业务数据。',
+    dependencies: ['warehouse', 'supplier', 'store', 'order_main'],
+    recordCount: 48,
+    fields: [
+      { name: 'metrics_id', type: 'BIGINT', constraint: 'PRIMARY KEY AUTO_INCREMENT', description: '指标 ID(主键自增)' },
+      { name: 'metrics_type', type: 'VARCHAR(32)', constraint: 'NOT NULL', description: '指标类型:inventory / supplier / store / order' },
+      { name: 'biz_code', type: 'VARCHAR(64)', constraint: 'NOT NULL', description: '业务编码:仓库编码 / 供应商 ID / 店铺编码 / 订单编号' },
+      { name: 'stat_start_date', type: 'DATE', constraint: 'NOT NULL', description: '统计开始日期' },
+      { name: 'stat_end_date', type: 'DATE', constraint: 'NOT NULL', description: '统计结束日期' }
+    ],
+    previewColumns: [
+      { prop: 'metrics_type', label: '指标类型', minWidth: 120 },
+      { prop: 'biz_code', label: '业务编码', minWidth: 160 },
+      { prop: 'stat_start_date', label: '开始日期', minWidth: 120 },
+      { prop: 'stat_end_date', label: '结束日期', minWidth: 120 },
+      { prop: 'metric_value', label: '指标值', minWidth: 120 }
+    ],
+    sampleRows: [
+      { metrics_type: 'inventory', biz_code: 'WH-SH-01', stat_start_date: '2026-04-01', stat_end_date: '2026-04-15', metric_value: '92.4' },
+      { metrics_type: 'supplier', biz_code: 'SUP-001', stat_start_date: '2026-04-01', stat_end_date: '2026-04-15', metric_value: '98.1' },
+      { metrics_type: 'store', biz_code: 'ST-TM-01', stat_start_date: '2026-04-01', stat_end_date: '2026-04-15', metric_value: '87.6' }
+    ]
+  }
+]
+
+function createUploadTables() {
+  return JSON.parse(JSON.stringify(UPLOAD_TABLES)).map(item => ({
+    ...item,
+    uploaded: false,
+    fileName: '',
+    fileSize: '',
+    updatedAt: ''
+  }))
+}
+
+export default {
+  name: 'UploadIndex',
+  data() {
+    return {
+      uploadTables: createUploadTables(),
+      activeTableKey: 'warehouse',
+      activePreviewTab: 'warehouse'
+    }
+  },
+  computed: {
+    orderedTables() {
+      return this.uploadTables.slice().sort((a, b) => a.order - b.order)
+    },
+    selectedTable() {
+      return this.uploadTables.find(item => item.key === this.activeTableKey) || this.uploadTables[0]
+    },
+    completedTableCount() {
+      return this.uploadTables.filter(item => item.uploaded).length
+    },
+    currentRecordTotal() {
+      return this.uploadTables.reduce((sum, item) => sum + item.recordCount, 0)
+    },
+    progressPercent() {
+      return Math.round((this.completedTableCount / this.uploadTables.length) * 100)
+    },
+    nextPendingTable() {
+      return this.orderedTables.find(item => !item.uploaded) || null
+    },
+    nextPendingLabel() {
+      return this.nextPendingTable ? `${this.nextPendingTable.order}. ${this.nextPendingTable.name}` : '全部完成'
+    }
+  },
+  methods: {
+    selectTable(key) {
+      this.activeTableKey = key
+      this.activePreviewTab = key
+    },
+    handlePreviewTabChange(tab) {
+      this.activeTableKey = tab.name
+    },
+    canUpload(table) {
+      return this.uploadTables
+        .filter(item => item.order < table.order)
+        .every(item => item.uploaded)
+    },
+    getBlockedReason(table) {
+      const blocked = this.uploadTables
+        .filter(item => item.order < table.order)
+        .sort((a, b) => a.order - b.order)
+        .find(item => !item.uploaded)
+
+      if (!blocked) {
+        return '当前步骤已解锁'
+      }
+
+      return `请先完成第 ${blocked.order} 步:${blocked.name}`
+    },
+    getStatusType(table) {
+      if (table.uploaded) {
+        return 'success'
+      }
+
+      return this.canUpload(table) ? 'warning' : 'info'
+    },
+    getStatusText(table) {
+      if (table.uploaded) {
+        return '已上传'
+      }
+
+      if (table.fileName && this.canUpload(table)) {
+        return '待提交'
+      }
+
+      return this.canUpload(table) ? '可上传' : '等待前置'
+    },
+    formatDependencies(table) {
+      return table.dependencies.length ? table.dependencies.join(' / ') : '无外键依赖'
+    },
+    triggerFileSelect(table) {
+      if (!this.canUpload(table) && !table.uploaded) {
+        this.$message.warning(this.getBlockedReason(table))
+        return
+      }
+
+      const inputRef = this.$refs[`fileInput-${table.key}`]
+      const input = Array.isArray(inputRef) ? inputRef[0] : inputRef
+
+      if (input) {
+        input.click()
+      }
+    },
+    handleFileChange(table, event) {
+      const file = event.target.files && event.target.files[0]
+      if (!file) {
+        return
+      }
+
+      table.fileName = file.name
+      table.fileSize = this.formatFileSize(file.size)
+      table.updatedAt = '已选择文件,待提交'
+
+      event.target.value = ''
+    },
+    submitTable(table) {
+      if (!this.canUpload(table) && !table.uploaded) {
+        this.$message.warning(this.getBlockedReason(table))
+        return
+      }
+
+      if (!table.fileName) {
+        this.$message.warning(`请先为${table.name}选择上传文件`)
+        return
+      }
+
+      table.uploaded = true
+      table.updatedAt = `${this.getCurrentTime()} 已模拟上传`
+      this.$message.success(`${table.name}上传成功(前端演示)`)
+
+      const nextTable = this.orderedTables.find(item => item.order === table.order + 1)
+      if (nextTable) {
+        this.selectTable(nextTable.key)
+      }
+    },
+    resetFromTable(table) {
+      this.uploadTables.forEach(item => {
+        if (item.order >= table.order) {
+          item.uploaded = false
+          item.fileName = ''
+          item.fileSize = ''
+          item.updatedAt = ''
+        }
+      })
+
+      this.selectTable(table.key)
+      this.$message.info(`已清空 ${table.name} 及其后续步骤`)
+    },
+    resetAllProgress() {
+      this.uploadTables = createUploadTables()
+      this.activeTableKey = 'warehouse'
+      this.activePreviewTab = 'warehouse'
+      this.$message.success('上传进度已重置')
+    },
+    focusNextPending() {
+      if (this.nextPendingTable) {
+        this.selectTable(this.nextPendingTable.key)
+        return
+      }
+
+      this.$message.success('全部上传步骤已完成')
+    },
+    formatFileSize(size) {
+      if (!size) {
+        return '0 B'
+      }
+
+      if (size < 1024) {
+        return `${size} B`
+      }
+
+      if (size < 1024 * 1024) {
+        return `${(size / 1024).toFixed(1)} KB`
+      }
+
+      return `${(size / (1024 * 1024)).toFixed(1)} MB`
+    },
+    getCurrentTime() {
+      const now = new Date()
+      const year = now.getFullYear()
+      const month = `${now.getMonth() + 1}`.padStart(2, '0')
+      const day = `${now.getDate()}`.padStart(2, '0')
+      const hour = `${now.getHours()}`.padStart(2, '0')
+      const minute = `${now.getMinutes()}`.padStart(2, '0')
+
+      return `${year}-${month}-${day} ${hour}:${minute}`
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.upload-page {
+  background: #f5f7fb;
+}
+
+.page-header {
+  margin-bottom: 20px;
+
+  h2 {
+    margin: 0 0 8px;
+    font-size: 24px;
+    font-weight: 600;
+    color: #303133;
+
+    i {
+      margin-right: 8px;
+      color: #409eff;
+    }
+  }
+
+  .page-desc {
+    margin: 0;
+    color: #606266;
+    line-height: 1.6;
+  }
+}
+
+.metrics-row,
+.content-row {
+  margin-bottom: 20px;
+}
+
+.metric-card {
+  border: none;
+
+  ::v-deep .el-card__body {
+    padding: 22px;
+  }
+}
+
+.metric-content {
+  display: flex;
+  align-items: center;
+}
+
+.metric-icon {
+  width: 52px;
+  height: 52px;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16px;
+  color: #fff;
+  font-size: 22px;
+}
+
+.icon-blue {
+  background: linear-gradient(135deg, #409eff, #63b3ff);
+}
+
+.icon-green {
+  background: linear-gradient(135deg, #67c23a, #85ce61);
+}
+
+.icon-orange {
+  background: linear-gradient(135deg, #e6a23c, #f3c36b);
+}
+
+.icon-red {
+  background: linear-gradient(135deg, #f56c6c, #ff8f8f);
+}
+
+.metric-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.metric-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+
+.metric-value {
+  font-size: 26px;
+  font-weight: 700;
+  color: #303133;
+  line-height: 1.2;
+}
+
+.metric-sub {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.next-step {
+  font-size: 18px;
+}
+
+.overview-card,
+.order-card,
+.detail-card,
+.snapshot-card {
+  border: none;
+}
+
+.overview-card {
+  margin-bottom: 20px;
+}
+
+.overview-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 18px;
+}
+
+.overview-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 8px;
+}
+
+.overview-desc {
+  color: #606266;
+  line-height: 1.7;
+}
+
+.overview-actions {
+  display: flex;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.overview-progress {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+
+  span {
+    flex-shrink: 0;
+    color: #606266;
+  }
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  font-weight: 600;
+}
+
+.order-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.order-item {
+  display: flex;
+  gap: 14px;
+  padding: 16px;
+  border: 1px solid #ebeef5;
+  border-radius: 12px;
+  background: #fff;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #c6e2ff;
+    box-shadow: 0 8px 18px rgba(64, 158, 255, 0.08);
+  }
+
+  &.active {
+    border-color: #409eff;
+    background: #f5faff;
+  }
+
+  &.uploaded {
+    border-color: #d9f2d0;
+    background: #f6ffed;
+  }
+
+  &.locked {
+    background: #fafafa;
+  }
+}
+
+.order-index {
+  width: 34px;
+  height: 34px;
+  border-radius: 10px;
+  background: #ecf5ff;
+  color: #409eff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+
+.order-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.order-title-row {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 6px;
+}
+
+.order-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.order-table {
+  margin-top: 2px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.order-desc {
+  color: #606266;
+  line-height: 1.6;
+  margin-bottom: 10px;
+}
+
+.order-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.order-footer {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.detail-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-bottom: 16px;
+}
+
+.detail-alert {
+  margin-bottom: 16px;
+}
+
+.table-descriptions {
+  margin-bottom: 16px;
+}
+
+.selected-file {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 14px;
+  border-radius: 10px;
+  background: #f4f9ff;
+  color: #409eff;
+  margin-bottom: 16px;
+
+  .file-size {
+    color: #909399;
+  }
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.section-with-gap {
+  margin-top: 18px;
+}
+
+.data-table {
+  width: 100%;
+}
+
+.snapshot-meta {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.snapshot-desc {
+  color: #606266;
+  font-size: 13px;
+}
+
+.hidden-inputs {
+  display: none;
+}
+
+@media (max-width: 1200px) {
+  .overview-top {
+    flex-direction: column;
+  }
+
+  .overview-actions {
+    width: 100%;
+  }
+}
+
+@media (max-width: 768px) {
+  .metric-value {
+    font-size: 22px;
+  }
+
+  .order-title-row,
+  .card-header {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .overview-progress {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+}
+</style>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor