Bläddra i källkod

生命周期模块AI决策功能+数据上传逻辑更新

Zhu Jiaqi 2 veckor sedan
förälder
incheckning
2eab7205d7

+ 9 - 0
src/api/lifecycleDatabase.js

@@ -24,6 +24,15 @@ export function getLifecycleDatabaseSpuResults(params) {
   })
 }
 
+export function analyzeLifecycleDatabaseWithAi(data) {
+  return request({
+    url: '/lifecycle/database/ai-analysis',
+    method: 'post',
+    timeout: 35000,
+    data
+  })
+}
+
 export function getLifecycleDatabaseHotProductSkuResults(params) {
   return request({
     url: '/lifecycle/hotproduct/database/sku-results',

+ 8 - 2
src/api/uploadData.js

@@ -1,8 +1,11 @@
 import request from '@/utils/request'
 
-export function uploadPreparedData(key, file) {
+export function uploadPreparedData(key, file, fieldMappings) {
   const data = new FormData()
   data.append('file', file)
+  if (fieldMappings) {
+    data.append('fieldMappings', JSON.stringify(fieldMappings))
+  }
 
   return request({
     url: `/api/upload-data/${key}`,
@@ -16,9 +19,12 @@ export function uploadPreparedData(key, file) {
   })
 }
 
-export function previewPreparedData(key, file) {
+export function previewPreparedData(key, file, fieldMappings) {
   const data = new FormData()
   data.append('file', file)
+  if (fieldMappings) {
+    data.append('fieldMappings', JSON.stringify(fieldMappings))
+  }
 
   return request({
     url: `/api/upload-data/${key}/preview`,

+ 33 - 3
src/views/index.vue

@@ -18,11 +18,11 @@
             <span>{{ module.children.length }}</span>
           </div> -->
           <div class="module-icon">
-            <svg-icon v-if="module.meta && module.meta.icon" :icon-class="module.meta.icon" class="icon" />
+            <svg-icon v-if="getModuleIcon(module)" :icon-class="getModuleIcon(module)" class="icon" />
             <i v-else class="el-icon-menu icon"></i>
           </div>
           <div class="module-content">
-            <div class="module-title">{{ module.meta ? module.meta.title : module.name }}</div>
+            <div class="module-title">{{ getModuleTitle(module) }}</div>
             <div class="module-desc" v-if="module.children && module.children.length">
               {{ module.children.length }} 个功能
             </div>
@@ -1054,15 +1054,45 @@ export default {
     goTarget(href) {
       window.open(href, "_blank")
     },
+    getVisibleChildren(module) {
+      return module && module.children ? module.children.filter(child => !child.hidden) : []
+    },
+    getOnlyVisibleChild(module) {
+      const children = this.getVisibleChildren(module)
+      return children.length === 1 ? children[0] : null
+    },
+    getModuleTitle(module) {
+      const onlyChild = this.getOnlyVisibleChild(module)
+      return (module.meta && module.meta.title) || (onlyChild && onlyChild.meta && onlyChild.meta.title) || module.name
+    },
+    getModuleIcon(module) {
+      const onlyChild = this.getOnlyVisibleChild(module)
+      return (module.meta && module.meta.icon) || (onlyChild && onlyChild.meta && onlyChild.meta.icon) || ''
+    },
+    getFullChildPath(module, child) {
+      if (!child || !child.path) {
+        return module.path
+      }
+      if (child.path.startsWith('/')) {
+        return child.path
+      }
+      const parentPath = module.path === '/' ? '' : module.path.replace(/\/$/, '')
+      return `${parentPath}/${child.path}`
+    },
     // 处理模块点击
     handleModuleClick(module) {
+      const onlyChild = this.getOnlyVisibleChild(module)
+      if (onlyChild && !module.alwaysShow) {
+        this.$router.push(this.getFullChildPath(module, onlyChild))
+        return
+      }
       // 如果有子菜单,跳转到子菜单页面
       if (module.children && module.children.length > 0) {
         this.$router.push({
           path: '/module-submenu',
           query: {
             modulePath: module.path,
-            moduleTitle: module.meta ? module.meta.title : module.name
+            moduleTitle: this.getModuleTitle(module)
           }
         })
       } else {

+ 478 - 39
src/views/lifecycle/lifecycleAnalysis/index.vue

@@ -166,9 +166,21 @@
         <div class="panel-header">
           <div>
             <h3>阶段建议</h3>
-            <p class="panel-subtitle">按当前{{ currentConfig.entityName }}的生命周期阶段生成运营动作,后续可接入 AI 输出个性化建议。</p>
+            <p class="panel-subtitle">按当前{{ currentConfig.entityName }}的生命周期阶段生成运营动作,并可调用大模型输出个性化建议。</p>
+          </div>
+          <div class="ai-actions">
+            <el-tag type="info" effect="plain">当前阶段:{{ currentStage }}</el-tag>
+            <el-button
+              type="primary"
+              size="small"
+              icon="el-icon-magic-stick"
+              :loading="aiAnalysisLoading"
+              :disabled="!selectedValue || !detail"
+              @click="generateAiAnalysis"
+            >
+              AI分析当前{{ currentConfig.entityName }}
+            </el-button>
           </div>
-          <el-tag type="info" effect="plain">当前阶段:{{ currentStage }}</el-tag>
         </div>
         <div class="advice-grid">
           <div
@@ -193,6 +205,60 @@
             </ul>
           </div>
         </div>
+
+        <el-alert
+          v-if="aiAnalysisError"
+          :title="aiAnalysisError"
+          type="error"
+          show-icon
+          class="ai-alert"
+        />
+
+        <div v-if="currentAiAnalysis" class="ai-analysis-panel">
+          <div class="ai-analysis-header">
+            <div>
+              <h4>AI个性化分析</h4>
+              <p>{{ currentAiAnalysis.summary }}</p>
+            </div>
+            <div class="ai-tags">
+              <el-tag v-if="currentAiAnalysis.fallback" type="warning" size="mini">规则兜底</el-tag>
+              <el-tag type="success" size="mini">{{ currentAiAnalysis.model || 'AI' }}</el-tag>
+              <span>{{ currentAiAnalysis.generatedAt }}</span>
+            </div>
+          </div>
+          <div v-if="currentAiAnalysis.fallbackReason" class="fallback-reason">
+            {{ currentAiAnalysis.fallbackReason }}
+          </div>
+          <div class="ai-section-grid">
+            <div class="ai-section">
+              <h5>当前阶段动作</h5>
+              <ul>
+                <li v-for="item in normalizeAiList(currentAiAnalysis.currentStageAdvice)" :key="item">{{ item }}</li>
+              </ul>
+            </div>
+            <div class="ai-section">
+              <h5>风险点</h5>
+              <ul>
+                <li v-for="item in normalizeAiList(currentAiAnalysis.risks)" :key="item">{{ item }}</li>
+              </ul>
+            </div>
+            <div class="ai-section">
+              <h5>下一步</h5>
+              <ul>
+                <li v-for="item in normalizeAiList(currentAiAnalysis.nextActions)" :key="item">{{ item }}</li>
+              </ul>
+            </div>
+          </div>
+          <div class="ai-stage-list">
+            <div v-for="item in normalizeStageAdvice(currentAiAnalysis.stageAdvice)" :key="item.stage" class="ai-stage-item">
+              <h5>{{ item.stage }}</h5>
+              <p>{{ item.analysis }}</p>
+              <ul>
+                <li v-for="suggestion in item.suggestions" :key="suggestion">{{ suggestion }}</li>
+              </ul>
+            </div>
+          </div>
+        </div>
       </div>
 
       <div class="panel">
@@ -223,6 +289,7 @@
 import { Chart } from 'chart.js'
 import { formatCurrency, formatDate } from '../../../utils/format'
 import {
+  analyzeLifecycleDatabaseWithAi,
   getLifecycleDatabaseSkuResults,
   getLifecycleDatabaseSpuResults
 } from '@/api/lifecycleDatabase'
@@ -329,10 +396,13 @@ export default {
       activeView: localStorage.getItem(STORAGE_KEY) === 'spu' ? 'spu' : 'sku',
       dateRange: loadDateRange(),
       loading: false,
+      aiAnalysisLoading: false,
       error: '',
+      aiAnalysisError: '',
       lifecycleStatusFilter: 'all',
       trendChart: null,
       stageCompareChart: null,
+      aiAnalysisCache: {},
       cache: {
         sku: loadCachedAnalysis('sku'),
         spu: loadCachedAnalysis('spu')
@@ -450,14 +520,24 @@ export default {
     totalQty() {
       if (this.detail && this.detail.total_quantity != null) return this.detail.total_quantity
       return 0
+    },
+    aiAnalysisKey() {
+      if (!this.activeView || !this.selectedValue) return ''
+      const range = this.dateRange && this.dateRange.length === 2 ? this.dateRange.join('_') : 'all'
+      return `${this.activeView}:${this.selectedValue}:${range}`
+    },
+    currentAiAnalysis() {
+      return this.aiAnalysisKey ? this.aiAnalysisCache[this.aiAnalysisKey] : null
     }
   },
   watch: {
     activeView() {
       localStorage.setItem(STORAGE_KEY, this.activeView)
+      this.aiAnalysisError = ''
       this.initCurrentView()
     },
     dateRange() {
+      this.aiAnalysisError = ''
       try {
         localStorage.setItem(DATE_RANGE_KEY, JSON.stringify(this.dateRange || []))
       } catch (e) {}
@@ -466,6 +546,7 @@ export default {
       this.ensureCurrentSelection()
     },
     detail() {
+      this.aiAnalysisError = ''
       this.$nextTick(() => {
         this.renderTrend()
         this.renderStageCompare()
@@ -608,20 +689,22 @@ export default {
       }
 
       const detail = this.detail || {}
-      const revenue = detail.smoothed_revenue || detail.revenue_series || []
-      const qty = detail.smoothed_quantity || detail.quantity_series || []
+      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))
       const stagesMap = detail.stages_map || []
-      const stageColors = {
-        引入期: 'rgba(59,130,246,0.08)',
-        成长期: 'rgba(16,185,129,0.10)',
-        成熟期: 'rgba(245,158,11,0.10)',
-        衰退期: 'rgba(239,68,68,0.08)'
+      const stageStyles = {
+        引入期: { fill: '#dbeafe', line: '#3b82f6', text: '#1d4ed8' },
+        成长期: { fill: '#dcfce7', line: '#16a34a', text: '#15803d' },
+        成熟期: { fill: '#fef3c7', line: '#f59e0b', text: '#b45309' },
+        衰退期: { fill: '#fee2e2', line: '#ef4444', text: '#b91c1c' }
       }
-      const segments = this.buildStageSegments(stagesMap, labels)
+      const segments = this.buildStageSegments(stagesMap, rawDates)
       const peakRevenueIndex = this.resolvePeakIndex(detail.peak_revenue_date, detail.revenue_peak_idx, rawDates)
       const peakQtyIndex = this.resolvePeakIndex(detail.peak_quantity_date, detail.quantity_peak_idx, rawDates)
+      const findStageAtIndex = this.findStageAtIndex
+      const formatMoney = this.formatCurrency
 
       if (this.trendChart) this.trendChart.destroy()
       this.trendChart = new Chart(canvas, {
@@ -629,8 +712,29 @@ export default {
         data: {
           labels,
           datasets: [
-            { label: '销售额', data: revenue, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.15)', lineTension: 0.25, pointRadius: 0 },
-            { label: '销量', data: qty, borderColor: '#64748b', backgroundColor: 'rgba(100,116,139,0.15)', lineTension: 0.25, pointRadius: 0 }
+            {
+              label: '销售额',
+              data: revenue,
+              yAxisID: 'revenue-axis',
+              borderColor: '#2563eb',
+              backgroundColor: 'rgba(37,99,235,0.08)',
+              borderWidth: 2.5,
+              lineTension: 0.28,
+              pointRadius: 0,
+              pointHoverRadius: 4
+            },
+            {
+              label: '销量',
+              data: qty,
+              yAxisID: 'quantity-axis',
+              borderColor: '#64748b',
+              backgroundColor: 'rgba(100,116,139,0.08)',
+              borderWidth: 2,
+              borderDash: [6, 4],
+              lineTension: 0.28,
+              pointRadius: 0,
+              pointHoverRadius: 4
+            }
           ]
         },
         options: {
@@ -640,16 +744,63 @@ export default {
           tooltips: {
             callbacks: {
               title(context) {
-                return `日期: ${context[0].label}`
+                const index = context && context[0] ? context[0].index : -1
+                const stage = findStageAtIndex(index, segments)
+                return stage ? `日期: ${context[0].label}(${stage})` : `日期: ${context[0].label}`
               },
               label(context, data) {
-                return `${data.datasets[context.datasetIndex].label}: ${Number(context.value).toLocaleString()}`
+                const label = data.datasets[context.datasetIndex].label
+                const value = Number(context.value)
+                return label === '销售额' ? `${label}: ${formatMoney(value)}` : `${label}: ${value.toLocaleString()}`
               }
             }
           },
+          legend: {
+            labels: {
+              boxWidth: 10,
+              usePointStyle: true,
+              padding: 18
+            }
+          },
           scales: {
-            xAxes: [{ ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } }],
-            yAxes: [{ ticks: { beginAtZero: true } }]
+            xAxes: [{
+              gridLines: {
+                color: 'rgba(148,163,184,0.16)',
+                drawBorder: false
+              },
+              ticks: {
+                maxRotation: 0,
+                autoSkip: true,
+                maxTicksLimit: 12,
+                fontColor: '#64748b'
+              }
+            }],
+            yAxes: [{
+              id: 'revenue-axis',
+              position: 'left',
+              gridLines: {
+                color: 'rgba(148,163,184,0.18)',
+                drawBorder: false
+              },
+              ticks: {
+                beginAtZero: true,
+                fontColor: '#2563eb',
+                callback(value) {
+                  return Number(value) >= 10000 ? `${Math.round(Number(value) / 10000)}万` : Number(value).toLocaleString()
+                }
+              }
+            }, {
+              id: 'quantity-axis',
+              position: 'right',
+              gridLines: {
+                drawOnChartArea: false,
+                drawBorder: false
+              },
+              ticks: {
+                beginAtZero: true,
+                fontColor: '#64748b'
+              }
+            }]
           }
         },
         plugins: [{
@@ -659,14 +810,49 @@ export default {
             const xScale = chart.scales['x-axis-0']
             if (!segments.length) return
             ctx.save()
+            ctx.lineWidth = 1
             segments.forEach(segment => {
-              const startX = xScale.getPixelForValue(segment.start)
-              const endX = xScale.getPixelForValue(segment.end)
-              ctx.fillStyle = stageColors[segment.stage] || 'rgba(0,0,0,0.04)'
-              ctx.fillRect(startX, chartArea.top, endX - startX, chartArea.bottom - chartArea.top)
-              ctx.fillStyle = '#374151'
-              ctx.font = 'bold 12px sans-serif'
-              ctx.fillText(segment.stage, startX + 4, chartArea.top + 14)
+              const startPoint = xScale.getPixelForValue(segment.start)
+              const endPoint = xScale.getPixelForValue(segment.end)
+              const prevPoint = segment.start > 0 ? xScale.getPixelForValue(segment.start - 1) : chartArea.left
+              const nextPoint = segment.end < chart.data.labels.length - 1 ? xScale.getPixelForValue(segment.end + 1) : chartArea.right
+              const startX = segment.start > 0 ? (prevPoint + startPoint) / 2 : chartArea.left
+              const endX = segment.end < chart.data.labels.length - 1 ? (endPoint + nextPoint) / 2 : chartArea.right
+              const style = stageStyles[segment.stage] || { fill: '#e5e7eb', line: '#94a3b8', text: '#475569' }
+              const bandTop = chartArea.top + 4
+              const bandHeight = 18
+              ctx.fillStyle = style.fill
+              ctx.fillRect(startX, bandTop, Math.max(1, endX - startX), bandHeight)
+              ctx.strokeStyle = style.line
+              ctx.strokeRect(startX, bandTop, Math.max(1, endX - startX), bandHeight)
+              if (segment.start > 0) {
+                ctx.strokeStyle = style.line
+                ctx.beginPath()
+                ctx.moveTo(startX, chartArea.top)
+                ctx.lineTo(startX, chartArea.bottom)
+                ctx.stroke()
+              }
+              if (endX - startX >= 42) {
+                ctx.fillStyle = style.text
+                ctx.textBaseline = 'middle'
+                ctx.textAlign = 'center'
+                ctx.font = 'bold 11px sans-serif'
+                ctx.fillText(segment.stage, startX + (endX - startX) / 2, bandTop + bandHeight / 2)
+              }
+            })
+            segments.forEach(segment => {
+              if (segment.end >= chart.data.labels.length - 1) return
+              const endPoint = xScale.getPixelForValue(segment.end)
+              const nextPoint = xScale.getPixelForValue(segment.end + 1)
+              const boundaryX = (endPoint + nextPoint) / 2
+              const style = stageStyles[segment.stage] || { line: '#94a3b8' }
+              ctx.strokeStyle = style.line
+              ctx.setLineDash([4, 4])
+              ctx.beginPath()
+              ctx.moveTo(boundaryX, chartArea.top)
+              ctx.lineTo(boundaryX, chartArea.bottom)
+              ctx.stroke()
+              ctx.setLineDash([])
             })
             ctx.restore()
           }
@@ -674,10 +860,11 @@ export default {
           afterDatasetsDraw(chart) {
             const ctx = chart.ctx
             const xScale = chart.scales['x-axis-0']
-            const yScale = chart.scales['y-axis-0']
+            const revenueScale = chart.scales['revenue-axis']
+            const quantityScale = chart.scales['quantity-axis']
             ctx.save()
-            drawPeak(ctx, xScale, yScale, revenue, peakRevenueIndex, '#dc2626', '销售额峰值')
-            drawPeak(ctx, xScale, yScale, qty, peakQtyIndex, '#2563eb', '销量峰值')
+            drawPeak(ctx, xScale, revenueScale, revenue, peakRevenueIndex, '#dc2626', '销售额峰值')
+            drawPeak(ctx, xScale, quantityScale, qty, peakQtyIndex, '#475569', '销量峰值')
             ctx.restore()
           }
         }]
@@ -691,25 +878,111 @@ export default {
         ctx.beginPath()
         ctx.arc(x, y, 5, 0, Math.PI * 2)
         ctx.fill()
+        ctx.font = 'bold 11px sans-serif'
+        const textWidth = ctx.measureText(label).width + 12
+        ctx.fillStyle = 'rgba(255,255,255,0.92)'
+        ctx.strokeStyle = color
+        ctx.lineWidth = 1
+        ctx.beginPath()
+        ctx.rect(x + 8, y - 23, textWidth, 18)
+        ctx.fill()
+        ctx.stroke()
         ctx.fillStyle = color
-        ctx.font = 'bold 12px sans-serif'
-        ctx.fillText(label, x + 10, y - 10)
+        ctx.fillText(label, x + 14, y - 10)
       }
     },
-    buildStageSegments(stagesMap, labels) {
+    buildStageSegments(stagesMap, rawDates) {
       const segments = []
-      if (!stagesMap.length || stagesMap.length !== labels.length) return segments
-      let currentStage = stagesMap[0]
-      let segmentStart = 0
-      for (let i = 1; i < stagesMap.length; i++) {
-        if (stagesMap[i] !== currentStage) {
-          segments.push({ start: segmentStart, end: i - 1, stage: currentStage })
-          currentStage = stagesMap[i]
-          segmentStart = i
+      const dates = rawDates || []
+      if (!dates.length) return segments
+      const stageStatSegments = this.buildStageSegmentsFromStats(dates)
+      if (stageStatSegments.length) return stageStatSegments
+      if (stagesMap && stagesMap.length === dates.length) {
+        let currentStage = stagesMap[0]
+        let segmentStart = 0
+        for (let i = 1; i < stagesMap.length; i++) {
+          if (stagesMap[i] !== currentStage) {
+            segments.push({ start: segmentStart, end: i - 1, stage: currentStage })
+            currentStage = stagesMap[i]
+            segmentStart = i
+          }
         }
+        segments.push({ start: segmentStart, end: stagesMap.length - 1, stage: currentStage })
+        return segments.filter(segment => segment.stage && segment.stage !== '数据不足')
+      }
+      const normalizedDates = dates.map(date => this.normalizeDate(date))
+      Object.keys(this.displayStageStats || {}).forEach(stage => {
+        const stats = this.displayStageStats[stage] || {}
+        const startDate = this.normalizeDate(stats.startDate)
+        const endDate = this.normalizeDate(stats.endDate)
+        if (!startDate || !endDate) return
+        let start = normalizedDates.findIndex(date => date >= startDate)
+        let end = -1
+        for (let i = normalizedDates.length - 1; i >= 0; i--) {
+          if (normalizedDates[i] <= endDate) {
+            end = i
+            break
+          }
+        }
+        if (start >= 0 && end >= start) {
+          segments.push({ start, end, stage })
+        }
+      })
+      return segments.sort((a, b) => a.start - b.start)
+    },
+    buildStageSegmentsFromStats(rawDates) {
+      const normalizedDates = (rawDates || []).map(date => this.normalizeDate(date))
+      const segments = []
+      stageOrder.forEach(stage => {
+        const stats = (this.displayStageStats && this.displayStageStats[stage]) || null
+        if (!stats) return
+        const startDate = this.normalizeDate(stats.startDate)
+        const endDate = this.normalizeDate(stats.endDate)
+        if (!startDate || !endDate) return
+        let start = normalizedDates.findIndex(date => date >= startDate)
+        let end = -1
+        for (let i = normalizedDates.length - 1; i >= 0; i--) {
+          if (normalizedDates[i] <= endDate) {
+            end = i
+            break
+          }
+        }
+        if (start >= 0 && end >= start) {
+          segments.push({ start, end, stage })
+        }
+      })
+      return segments.sort((a, b) => a.start - b.start)
+    },
+    findStageAtIndex(index, segments) {
+      if (index == null || index < 0 || !Array.isArray(segments)) return ''
+      const segment = segments.find(item => index >= item.start && index <= item.end)
+      return segment ? segment.stage : ''
+    },
+    buildLifecycleDataForAi() {
+      const detail = this.detail || {}
+      return {
+        entity_id: this.selectedValue,
+        details: detail.details,
+        current_stage: detail.current_stage,
+        is_complete: detail.is_complete,
+        insufficient: detail.insufficient,
+        completeness_score: detail.completeness_score,
+        total_revenue: detail.total_revenue,
+        total_quantity: detail.total_quantity,
+        peak_revenue: detail.peak_revenue,
+        peak_revenue_date: detail.peak_revenue_date,
+        peak_quantity: detail.peak_quantity,
+        peak_quantity_date: detail.peak_quantity_date,
+        revenue_peak_idx: detail.revenue_peak_idx,
+        quantity_peak_idx: detail.quantity_peak_idx,
+        next_stage_prediction: detail.next_stage_prediction,
+        stage_statistics: this.displayStageStats,
+        completion_details: detail.completion_details || {},
+        date_series: detail.date_series || [],
+        revenue_series: detail.revenue_series || detail.smoothed_revenue || [],
+        quantity_series: detail.quantity_series || detail.smoothed_quantity || [],
+        stages_map: detail.stages_map || []
       }
-      segments.push({ start: segmentStart, end: stagesMap.length - 1, stage: currentStage })
-      return segments
     },
     resolvePeakIndex(date, fallback, rawDates) {
       if (date && rawDates.length > 0) {
@@ -764,6 +1037,57 @@ export default {
       const day = String(target.getDate()).padStart(2, '0')
       return `${year}-${month}-${day}`
     },
+    getAiAnalysisPayload() {
+      const payload = {
+        entityType: this.activeView,
+        entityId: this.selectedValue
+      }
+      if (this.dateRange && this.dateRange.length === 2) {
+        payload.startDate = this.dateRange[0]
+        payload.endDate = this.dateRange[1]
+      }
+      payload.lifecycleData = this.buildLifecycleDataForAi()
+      return payload
+    },
+    async generateAiAnalysis() {
+      if (!this.selectedValue || !this.detail || this.aiAnalysisLoading) return
+      if (this.currentAiAnalysis) {
+        this.$message.info('已使用当前筛选条件下的AI分析缓存')
+        return
+      }
+      this.aiAnalysisLoading = true
+      this.aiAnalysisError = ''
+      try {
+        const response = await analyzeLifecycleDatabaseWithAi(this.getAiAnalysisPayload())
+        const result = response && response.data ? response.data : null
+        if (!result) {
+          throw new Error('AI分析结果为空')
+        }
+        this.$set(this.aiAnalysisCache, this.aiAnalysisKey, result)
+      } catch (error) {
+        this.aiAnalysisError = (error && error.message) || 'AI分析失败,请稍后重试'
+      } finally {
+        this.aiAnalysisLoading = false
+      }
+    },
+    normalizeAiList(value) {
+      if (Array.isArray(value)) {
+        return value.filter(item => item != null && String(item).trim()).map(item => String(item))
+      }
+      if (value == null || String(value).trim() === '') return []
+      return [String(value)]
+    },
+    normalizeStageAdvice(value) {
+      if (!Array.isArray(value)) return []
+      return value.map((item, index) => {
+        const stage = item && item.stage ? item.stage : `阶段${index + 1}`
+        return {
+          stage,
+          analysis: item && item.analysis ? item.analysis : '',
+          suggestions: this.normalizeAiList(item && item.suggestions)
+        }
+      })
+    },
     exportResults() {
       if (!this.hasRawResults) {
         this.$modal.msgError('暂无可导出的分析结果')
@@ -1194,6 +1518,110 @@ export default {
   margin-top: 6px;
 }
 
+.ai-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.ai-alert {
+  margin-top: 16px;
+}
+
+.ai-analysis-panel {
+  margin-top: 20px;
+  border: 1px solid #bfdbfe;
+  border-radius: 8px;
+  background: #f8fbff;
+  padding: 18px;
+}
+
+.ai-analysis-header {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  align-items: flex-start;
+
+  h4 {
+    margin: 0 0 8px;
+    color: #1f2937;
+    font-size: 17px;
+  }
+
+  p {
+    margin: 0;
+    color: #475569;
+    line-height: 1.7;
+    font-size: 14px;
+  }
+}
+
+.ai-tags {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  color: #64748b;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.fallback-reason {
+  margin-top: 12px;
+  color: #92400e;
+  background: #fffbeb;
+  border: 1px solid #fde68a;
+  border-radius: 6px;
+  padding: 8px 10px;
+  font-size: 13px;
+}
+
+.ai-section-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 14px;
+  margin-top: 16px;
+}
+
+.ai-section,
+.ai-stage-item {
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  background: #ffffff;
+  padding: 14px;
+}
+
+.ai-section h5,
+.ai-stage-item h5 {
+  margin: 0 0 10px;
+  color: #334155;
+  font-size: 14px;
+}
+
+.ai-section ul,
+.ai-stage-item ul {
+  margin: 0;
+  padding-left: 18px;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.ai-stage-list {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+  gap: 14px;
+  margin-top: 14px;
+}
+
+.ai-stage-item p {
+  margin: 0 0 10px;
+  color: #64748b;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
 .breakdown-grid {
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -1252,5 +1680,16 @@ export default {
   .status-select {
     width: 100%;
   }
+
+  .ai-analysis-header,
+  .ai-actions {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .ai-tags {
+    flex-wrap: wrap;
+    white-space: normal;
+  }
 }
 </style>

+ 2 - 1
src/views/module-submenu.vue

@@ -68,7 +68,8 @@ export default {
               .filter(child => !child.hidden)
               .map(child => {
                 // 如果路径是相对路径,需要拼接父路径
-                const fullPath = child.path.startsWith('/') ? child.path : `${route.path}/${child.path}`
+                const parentPath = route.path === '/' ? '' : route.path.replace(/\/$/, '')
+                const fullPath = child.path.startsWith('/') ? child.path : `${parentPath}/${child.path}`
                 return {
                   ...child,
                   path: fullPath

+ 287 - 5
src/views/upload/index.vue

@@ -102,12 +102,19 @@
             <el-button
               type="success"
               size="small"
-              :disabled="selectedTable.uploaded || selectedTable.uploading || selectedTable.previewLoading || !canUpload(selectedTable) || hasMissingFields(selectedTable)"
+              :disabled="selectedTable.uploaded || selectedTable.uploading || selectedTable.previewLoading || !canUpload(selectedTable) || !isMappingReady(selectedTable)"
               :loading="selectedTable.uploading"
               @click="submitTable(selectedTable)"
             >
               <i class="el-icon-upload"></i> 开始上传
             </el-button>
+            <el-button
+              size="small"
+              :disabled="!selectedTable.fileName || selectedTable.uploaded || selectedTable.previewLoading"
+              @click="openFieldMapping(selectedTable)"
+            >
+              <i class="el-icon-connection"></i> 字段映射
+            </el-button>
             <el-button
               size="small"
               :disabled="!selectedTable.fileName && !selectedTable.uploaded"
@@ -179,6 +186,24 @@
             </div>
           </el-alert>
 
+          <el-alert
+            v-else-if="selectedTable.fileName && !selectedTable.mappingConfirmed"
+            :closable="false"
+            type="warning"
+            show-icon
+            title="请先确认文件表头与数据库字段的映射关系,必要字段全部匹配后才能上传。"
+            class="detail-alert"
+          />
+
+          <el-alert
+            v-else-if="isMappingReady(selectedTable)"
+            :closable="false"
+            type="success"
+            show-icon
+            title="字段映射已确认,可以开始上传。"
+            class="detail-alert"
+          />
+
           <el-alert
             v-else-if="selectedTable.previewError"
             :closable="false"
@@ -263,6 +288,114 @@
         @change="handleFileChange(table, $event)"
       >
     </div>
+
+    <el-dialog
+      title="字段映射"
+      :visible.sync="mappingDialog.visible"
+      width="86%"
+      class="mapping-dialog"
+      :close-on-click-modal="false"
+    >
+      <template v-if="mappingDialog.table">
+        <div class="mapping-dialog-meta">
+          <el-tag size="small" type="info">{{ getUploadName(mappingDialog.table) }}</el-tag>
+          <span>文件:{{ mappingDialog.table.fileName }}</span>
+          <span>识别表头:{{ mappingDialog.table.fileHeaders.length }} 个</span>
+          <span>必要字段:{{ getMappedRequiredCount(mappingDialog.table) }}/{{ getRequiredFields(mappingDialog.table).length }}</span>
+        </div>
+
+        <el-alert
+          :closable="false"
+          :type="isMappingValid(mappingDialog.table) ? 'success' : 'warning'"
+          show-icon
+          class="detail-alert"
+          :title="isMappingValid(mappingDialog.table) ? '必要字段已全部匹配,可以确认映射。' : '仍有必要字段未匹配或存在重复映射,请调整。'"
+        />
+
+        <el-row :gutter="18">
+          <el-col :xs="24" :lg="15">
+            <el-table
+              :data="mappingDialog.table.databaseFields"
+              border
+              size="small"
+              class="mapping-table"
+            >
+              <el-table-column label="数据库字段" min-width="210">
+                <template slot-scope="scope">
+                  <div class="mapping-field-name">{{ scope.row.label || scope.row.name }}</div>
+                  <div class="mapping-field-meta">
+                    {{ scope.row.name }}
+                    <el-tag v-if="scope.row.required" size="mini" type="danger" effect="plain">必填</el-tag>
+                    <el-tag v-else size="mini" type="info" effect="plain">选填</el-tag>
+                  </div>
+                </template>
+              </el-table-column>
+              <el-table-column prop="type" label="字段类型" min-width="130" />
+              <el-table-column label="文件表头" min-width="220">
+                <template slot-scope="scope">
+                  <el-select
+                    :value="mappingDialog.table.fieldMappings[scope.row.name] || ''"
+                    clearable
+                    filterable
+                    placeholder="请选择表头"
+                    size="small"
+                    @change="value => handleMappingChange(mappingDialog.table, scope.row.name, value)"
+                  >
+                    <el-option label="未选择" value="" />
+                    <el-option
+                      v-for="header in mappingDialog.table.fileHeaders"
+                      :key="header"
+                      :label="header"
+                      :value="header"
+                    />
+                  </el-select>
+                </template>
+              </el-table-column>
+              <el-table-column label="校验状态" min-width="130">
+                <template slot-scope="scope">
+                  <el-tag size="mini" :type="getFieldStatus(mappingDialog.table, scope.row).type">
+                    {{ getFieldStatus(mappingDialog.table, scope.row).message }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-col>
+          <el-col :xs="24" :lg="9">
+            <div class="section-title">
+              <span>文件样例预览</span>
+              <el-tag size="mini" type="info">{{ mappingDialog.table.sampleRows.length }} 行</el-tag>
+            </div>
+            <el-table
+              :data="mappingDialog.table.sampleRows"
+              stripe
+              size="mini"
+              class="mapping-preview-table"
+            >
+              <el-table-column
+                v-for="column in mappingDialog.table.previewColumns"
+                :key="column.prop"
+                :prop="column.prop"
+                :label="column.label"
+                :min-width="column.minWidth || 120"
+                show-overflow-tooltip
+              />
+            </el-table>
+          </el-col>
+        </el-row>
+      </template>
+      <span slot="footer" class="dialog-footer">
+        <el-button size="small" @click="mappingDialog.visible = false">取消</el-button>
+        <el-button size="small" type="primary" @click="autoMatchMappings(mappingDialog.table)">自动推荐匹配</el-button>
+        <el-button
+          size="small"
+          type="success"
+          :disabled="!mappingDialog.table || !isMappingValid(mappingDialog.table)"
+          @click="confirmFieldMapping(mappingDialog.table)"
+        >
+          确认映射
+        </el-button>
+      </span>
+    </el-dialog>
   </div>
 </template>
 
@@ -684,14 +817,21 @@ const UPLOAD_NAMES = {
 }
 
 const BACKEND_UPLOAD_KEYS = [
+  'warehouse',
   'supplier',
   'semi_finished_product',
   'product',
+  'product_structure',
+  'store',
+  'structure_rule_setting',
+  'location',
   'bom_list',
   'purchase_receipt',
   'order_main',
   'assembly_record',
-  'store'
+  'outbound_order',
+  'inventory_detail',
+  'metrics'
 ]
 
 function createUploadTables() {
@@ -709,6 +849,19 @@ function createUploadTables() {
     previewLoading: false,
     previewError: '',
     missingFields: [],
+    duplicateHeaders: [],
+    databaseFields: item.fields.map(field => ({
+      name: field.name,
+      label: field.description || field.name,
+      type: field.type,
+      required: /NOT NULL|PRIMARY KEY/i.test(field.constraint || '')
+    })),
+    fileHeaders: [],
+    fieldMappings: {},
+    recommendedFieldMappings: {},
+    fieldStatuses: [],
+    mappingConfirmed: false,
+    mappingValid: false,
     updatedAt: ''
   }))
 }
@@ -719,7 +872,11 @@ export default {
     return {
       uploadTables: createUploadTables(),
       activeTableKey: 'supplier',
-      activePreviewTab: 'supplier'
+      activePreviewTab: 'supplier',
+      mappingDialog: {
+        visible: false,
+        table: null
+      }
     }
   },
   computed: {
@@ -861,6 +1018,10 @@ export default {
         return 'danger'
       }
 
+      if (table.fileName && !table.mappingConfirmed) {
+        return 'danger'
+      }
+
       return this.canUpload(table) ? 'warning' : 'info'
     },
     getStatusText(table) {
@@ -884,6 +1045,10 @@ export default {
         return '需调整'
       }
 
+      if (table.fileName && !table.mappingConfirmed) {
+        return '待映射'
+      }
+
       if (table.fileName && this.canUpload(table)) {
         return '待提交'
       }
@@ -932,6 +1097,13 @@ export default {
       table.previewLoading = true
       table.previewError = ''
       table.missingFields = []
+      table.duplicateHeaders = []
+      table.fileHeaders = []
+      table.fieldMappings = {}
+      table.recommendedFieldMappings = {}
+      table.fieldStatuses = []
+      table.mappingConfirmed = false
+      table.mappingValid = false
       table.sampleRows = []
       table.recordCount = 0
 
@@ -943,8 +1115,16 @@ export default {
         table.previewColumns = data.columns && data.columns.length ? data.columns : table.defaultPreviewColumns
         table.sampleRows = data.sampleRows || []
         table.recordCount = data.totalRows || 0
+        table.fileHeaders = data.headers || []
+        table.databaseFields = data.databaseFields && data.databaseFields.length ? data.databaseFields : table.databaseFields
+        table.fieldMappings = data.fieldMappings || {}
+        table.recommendedFieldMappings = Object.assign({}, table.fieldMappings)
+        table.fieldStatuses = data.fieldStatuses || []
         table.missingFields = data.missingFields || []
-        table.updatedAt = this.hasMissingFields(table) ? '文件内容需要调整' : '已选择文件,待提交'
+        table.duplicateHeaders = data.duplicateHeaders || []
+        table.mappingValid = Boolean(data.valid)
+        table.updatedAt = this.hasMissingFields(table) ? '文件字段映射需要调整' : '已选择文件,待确认字段映射'
+        this.openFieldMapping(table)
 
         if (this.hasMissingFields(table)) {
           this.$message.warning(`${this.getUploadName(table)}缺少必要内容:${this.formatMissingFields(table)}`)
@@ -978,9 +1158,15 @@ export default {
         return
       }
 
+      if (!this.isMappingReady(table)) {
+        this.$message.warning('请先确认字段映射,必要字段全部匹配后才能上传')
+        this.openFieldMapping(table)
+        return
+      }
+
       table.uploading = true
       try {
-        const response = await uploadPreparedData(table.key, table.rawFile)
+        const response = await uploadPreparedData(table.key, table.rawFile, table.fieldMappings)
         table.uploaded = true
         table.updatedAt = `${this.getCurrentTime()} 已上传`
         table.recordCount = (response.data && response.data.totalRows) || table.recordCount
@@ -1007,6 +1193,13 @@ export default {
           item.previewLoading = false
           item.previewError = ''
           item.missingFields = []
+          item.duplicateHeaders = []
+          item.fileHeaders = []
+          item.fieldMappings = {}
+          item.recommendedFieldMappings = {}
+          item.fieldStatuses = []
+          item.mappingConfirmed = false
+          item.mappingValid = false
           item.sampleRows = []
           item.recordCount = 0
           item.previewColumns = item.defaultPreviewColumns
@@ -1020,6 +1213,95 @@ export default {
     hasMissingFields(table) {
       return table && table.missingFields && table.missingFields.length > 0
     },
+    getRequiredFields(table) {
+      if (!table || !table.databaseFields) {
+        return []
+      }
+      return table.databaseFields.filter(field => field.required && !field.autoIncrement)
+    },
+    getMappedRequiredCount(table) {
+      return this.getRequiredFields(table).filter(field => table.fieldMappings && table.fieldMappings[field.name]).length
+    },
+    getDuplicateMappedHeaders(table) {
+      if (!table || !table.fieldMappings) {
+        return []
+      }
+      const selectedHeaders = Object.values(table.fieldMappings).filter(Boolean)
+      return selectedHeaders.filter((header, index) => selectedHeaders.indexOf(header) !== index)
+    },
+    getFieldStatus(table, field) {
+      const selectedHeader = table && table.fieldMappings ? table.fieldMappings[field.name] : ''
+      const duplicateHeaders = this.getDuplicateMappedHeaders(table)
+
+      if (field.required && !selectedHeader) {
+        return { type: 'danger', message: '必填未匹配' }
+      }
+
+      if (selectedHeader && duplicateHeaders.includes(selectedHeader)) {
+        return { type: 'danger', message: '重复映射' }
+      }
+
+      if (!selectedHeader) {
+        return { type: 'info', message: '选填未匹配' }
+      }
+
+      return { type: 'success', message: '匹配成功' }
+    },
+    isMappingValid(table) {
+      if (!table || !table.fileName) {
+        return false
+      }
+
+      const hasMissingRequired = this.getRequiredFields(table).some(field => !table.fieldMappings || !table.fieldMappings[field.name])
+      return !hasMissingRequired && this.getDuplicateMappedHeaders(table).length === 0
+    },
+    isMappingReady(table) {
+      return Boolean(table && table.fileName && table.mappingConfirmed && this.isMappingValid(table))
+    },
+    openFieldMapping(table) {
+      if (!table || !table.fileName) {
+        return
+      }
+      this.mappingDialog.table = table
+      this.mappingDialog.visible = true
+    },
+    handleMappingChange(table, fieldName, header) {
+      if (!table) {
+        return
+      }
+      this.$set(table.fieldMappings, fieldName, header)
+      table.mappingConfirmed = false
+      table.mappingValid = this.isMappingValid(table)
+      table.missingFields = this.getRequiredFields(table)
+        .filter(field => !table.fieldMappings[field.name])
+        .map(field => ({ name: field.name, label: field.label || field.name }))
+      table.duplicateHeaders = this.getDuplicateMappedHeaders(table)
+    },
+    autoMatchMappings(table) {
+      if (!table) {
+        return
+      }
+      table.fieldMappings = Object.assign({}, table.recommendedFieldMappings || {})
+      table.mappingConfirmed = false
+      table.mappingValid = this.isMappingValid(table)
+      table.missingFields = this.getRequiredFields(table)
+        .filter(field => !table.fieldMappings[field.name])
+        .map(field => ({ name: field.name, label: field.label || field.name }))
+      table.duplicateHeaders = this.getDuplicateMappedHeaders(table)
+    },
+    confirmFieldMapping(table) {
+      if (!this.isMappingValid(table)) {
+        this.$message.warning('必要字段未全部匹配或存在重复映射')
+        return
+      }
+      table.mappingConfirmed = true
+      table.mappingValid = true
+      table.missingFields = []
+      table.duplicateHeaders = []
+      table.updatedAt = '字段映射已确认,待提交'
+      this.mappingDialog.visible = false
+      this.$message.success(`${this.getUploadName(table)}字段映射已确认`)
+    },
     formatMissingFields(table) {
       if (!this.hasMissingFields(table)) {
         return ''

+ 1422 - 0
src/views/upload/index_v1.vue

@@ -0,0 +1,1422 @@
+<template>
+  <div class="app-container upload-page">
+    <div class="page-header">
+      <h2><i class="el-icon-upload2"></i> 资料上传</h2>
+      <p class="page-desc">请先处理页面上方标记为“可上传”的资料;暂未开放的资料会提示需要先上传哪些内容。</p>
+    </div>
+
+    <el-card class="overview-card hero-card">
+      <div class="overview-top">
+        <div>
+          <div class="overview-title">选择文件后即可上传</div>
+          <div class="overview-desc">
+            支持 CSV、XLS、XLSX 文件。页面已把可以直接上传的资料排在前面,暂不可上传的资料会在列表中给出原因。
+          </div>
+        </div>
+        <div class="hero-summary">
+          <div class="summary-item">
+            <span>{{ readyUploadCount }}</span>
+            <label>可上传</label>
+          </div>
+          <div class="summary-item">
+            <span>{{ completedTableCount }}</span>
+            <label>已完成</label>
+          </div>
+          <div class="summary-item">
+            <span>{{ waitingUploadCount }}</span>
+            <label>待准备</label>
+          </div>
+        </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="success">{{ readyUploadCount }} 项可处理</el-tag>
+            </div>
+          </template>
+
+          <div class="order-list">
+            <div
+              v-for="table in displayTables"
+              :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">
+                <i :class="getItemIcon(table)"></i>
+              </div>
+              <div class="order-main">
+                <div class="order-title-row">
+                  <div>
+                    <div class="order-title">{{ getUploadName(table) }}</div>
+                    <div class="order-table">{{ getUploadHint(table) }}</div>
+                  </div>
+                  <el-tag :type="getStatusType(table)" size="mini">
+                    {{ getStatusText(table) }}
+                  </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>{{ getUploadName(selectedTable) }}</span>
+              <el-tag size="mini" :type="getStatusType(selectedTable)">{{ getStatusText(selectedTable) }}</el-tag>
+            </div>
+          </template>
+
+          <div class="detail-actions">
+            <el-button
+              type="primary"
+              size="small"
+              :disabled="selectedTable.uploaded || selectedTable.uploading || !isBackendSupported(selectedTable)"
+              @click="triggerFileSelect(selectedTable)"
+            >
+              <i class="el-icon-folder-opened"></i> 选择文件
+            </el-button>
+            <el-button
+              type="success"
+              size="small"
+              :disabled="selectedTable.uploaded || selectedTable.uploading || selectedTable.previewLoading || !canUpload(selectedTable) || hasMissingFields(selectedTable)"
+              :loading="selectedTable.uploading"
+              @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 ? '这项资料已上传完成。' : '这项资料现在可以上传,请选择 CSV、XLS 或 XLSX 文件。'"
+          />
+
+          <div class="upload-dropzone" :class="{ disabled: selectedTable.uploaded || !isBackendSupported(selectedTable) }" @click="triggerFileSelect(selectedTable)">
+            <i class="el-icon-upload"></i>
+            <div>
+              <strong>{{ selectedTable.fileName || '点击选择上传文件' }}</strong>
+              <p>{{ selectedTable.fileName ? selectedTable.fileSize : (isBackendSupported(selectedTable) ? '支持 CSV、XLS、XLSX,选择后再点击开始上传' : '这项资料暂未接入上传') }}</p>
+            </div>
+          </div>
+
+          <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>
+
+          <el-alert
+            v-if="selectedTable.previewLoading"
+            :closable="false"
+            type="info"
+            show-icon
+            title="正在读取文件内容,请稍候"
+            class="detail-alert"
+          />
+
+          <el-alert
+            v-else-if="hasMissingFields(selectedTable)"
+            :closable="false"
+            type="error"
+            show-icon
+            class="detail-alert"
+          >
+            <div slot="title">文件缺少必要内容,暂不能上传</div>
+            <div class="missing-fields">
+              <el-tag
+                v-for="field in selectedTable.missingFields"
+                :key="field.name"
+                size="mini"
+                type="danger"
+                effect="plain"
+              >
+                {{ field.label || field.name }}
+              </el-tag>
+            </div>
+          </el-alert>
+
+          <el-alert
+            v-else-if="selectedTable.previewError"
+            :closable="false"
+            type="warning"
+            show-icon
+            :title="selectedTable.previewError"
+            class="detail-alert"
+          />
+
+          <div class="section-title section-with-gap">
+            <span>内容预览</span>
+            <el-tag size="mini" :type="selectedTable.fileName ? 'success' : 'info'">{{ getPreviewCountText(selectedTable) }}</el-tag>
+          </div>
+          <el-table
+            v-loading="selectedTable.previewLoading"
+            :data="selectedTable.sampleRows"
+            stripe
+            size="small"
+            class="data-table"
+            :empty-text="selectedTable.fileName ? '文件中没有可预览的数据' : '选择文件后展示真实数据'"
+          >
+            <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">可切换查看 {{ uploadTables.length }} 项</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">{{ getUploadName(table) }}</span>
+          <div class="snapshot-meta">
+            <el-tag size="mini" :type="getStatusType(table)">{{ getStatusText(table) }}</el-tag>
+            <el-tag size="mini" effect="plain" :type="table.fileName ? 'success' : 'info'">{{ getPreviewCountText(table) }}</el-tag>
+            <span class="snapshot-desc">{{ getUploadHint(table) }}</span>
+          </div>
+          <el-table
+            v-loading="table.previewLoading"
+            :data="table.sampleRows"
+            stripe
+            size="mini"
+            :empty-text="table.fileName ? '文件中没有可预览的数据' : '选择文件后展示真实数据'"
+          >
+            <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>
+import { previewPreparedData, uploadPreparedData } from '@/api/uploadData'
+
+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: '业务指标聚合结果,SQL 与设计文档未定义外键约束,可单独上传。',
+    dependencies: [],
+    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' }
+    ]
+  }
+]
+
+const UPLOAD_NAMES = {
+  warehouse: '仓库资料',
+  supplier: '供应商资料',
+  semi_finished_product: '半成品资料',
+  product: '产品资料',
+  product_structure: '产品分类资料',
+  store: '店铺资料',
+  structure_rule_setting: '编码规则资料',
+  location: '库位资料',
+  bom_list: '组合清单资料',
+  purchase_receipt: '采购入库资料',
+  order_main: '订单资料',
+  assembly_record: '组装记录资料',
+  outbound_order: '出库资料',
+  inventory_detail: '库存资料',
+  metrics: '经营指标资料'
+}
+
+const BACKEND_UPLOAD_KEYS = [
+  'supplier',
+  'semi_finished_product',
+  'product',
+  'bom_list',
+  'purchase_receipt',
+  'order_main',
+  'assembly_record',
+  'store'
+]
+
+function createUploadTables() {
+  return JSON.parse(JSON.stringify(UPLOAD_TABLES)).map(item => ({
+    ...item,
+    defaultPreviewColumns: item.previewColumns,
+    defaultSampleRows: item.sampleRows,
+    recordCount: 0,
+    sampleRows: [],
+    uploaded: false,
+    fileName: '',
+    fileSize: '',
+    rawFile: null,
+    uploading: false,
+    previewLoading: false,
+    previewError: '',
+    missingFields: [],
+    updatedAt: ''
+  }))
+}
+
+export default {
+  name: 'UploadIndex',
+  data() {
+    return {
+      uploadTables: createUploadTables(),
+      activeTableKey: 'supplier',
+      activePreviewTab: 'supplier'
+    }
+  },
+  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
+    },
+    progressPercent() {
+      return Math.round((this.completedTableCount / this.uploadTables.length) * 100)
+    },
+    readyUploadCount() {
+      return this.uploadTables.filter(item => !item.uploaded && this.canUpload(item)).length
+    },
+    waitingUploadCount() {
+      return this.uploadTables.filter(item => !item.uploaded && !this.canUpload(item)).length
+    },
+    displayTables() {
+      return this.orderedTables.slice().sort((a, b) => {
+        const aRank = this.getDisplayRank(a)
+        const bRank = this.getDisplayRank(b)
+
+        if (aRank !== bRank) {
+          return aRank - bRank
+        }
+
+        return a.order - b.order
+      })
+    }
+  },
+  methods: {
+    selectTable(key) {
+      this.activeTableKey = key
+      this.activePreviewTab = key
+    },
+    handlePreviewTabChange(tab) {
+      this.activeTableKey = tab.name
+    },
+    getTableByKey(key) {
+      return this.uploadTables.find(item => item.key === key)
+    },
+    getBlockedDependencies(table) {
+      return table.dependencies
+        .map(key => this.getTableByKey(key))
+        .filter(item => item && !item.uploaded)
+    },
+    getDisplayRank(table) {
+      if (!table.uploaded && this.canUpload(table)) {
+        return 0
+      }
+
+      if (!table.uploaded && this.isBackendSupported(table)) {
+        return 1
+      }
+
+      return table.uploaded ? 2 : 3
+    },
+    isBackendSupported(table) {
+      return BACKEND_UPLOAD_KEYS.includes(table.key)
+    },
+    getUploadName(table) {
+      return UPLOAD_NAMES[table.key] || table.name.replace(/表$/, '资料')
+    },
+    getUploadHint(table) {
+      if (table.uploaded) {
+        return table.updatedAt || '已完成'
+      }
+
+      if (!this.isBackendSupported(table)) {
+        return '资料文件暂未整理完成,暂不支持上传'
+      }
+
+      if (this.canUpload(table)) {
+        return '现在可以选择文件上传'
+      }
+
+      return this.getBlockedReason(table)
+    },
+    getItemIcon(table) {
+      if (table.uploaded) {
+        return 'el-icon-check'
+      }
+
+      if (!this.isBackendSupported(table)) {
+        return 'el-icon-document'
+      }
+
+      if (this.canUpload(table)) {
+        return 'el-icon-upload2'
+      }
+
+      return 'el-icon-lock'
+    },
+    getDependentKeys(table) {
+      const dependentKeys = new Set([table.key])
+      let changed = true
+
+      while (changed) {
+        changed = false
+        this.uploadTables.forEach(item => {
+          if (
+            !dependentKeys.has(item.key) &&
+            item.dependencies.some(dependency => dependentKeys.has(dependency))
+          ) {
+            dependentKeys.add(item.key)
+            changed = true
+          }
+        })
+      }
+
+      return dependentKeys
+    },
+    canUpload(table) {
+      return this.isBackendSupported(table) && this.getBlockedDependencies(table).length === 0
+    },
+    getBlockedReason(table) {
+      if (!this.isBackendSupported(table)) {
+        return '这项资料暂未接入上传'
+      }
+
+      const blocked = this.getBlockedDependencies(table)
+
+      if (!blocked.length) {
+        return '现在可以上传'
+      }
+
+      return `请先上传:${blocked.map(item => this.getUploadName(item)).join('、')}`
+    },
+    getStatusType(table) {
+      if (table.uploaded) {
+        return 'success'
+      }
+
+      if (this.hasMissingFields(table)) {
+        return 'danger'
+      }
+
+      return this.canUpload(table) ? 'warning' : 'info'
+    },
+    getStatusText(table) {
+      if (table.uploaded) {
+        return '已上传'
+      }
+
+      if (!this.isBackendSupported(table)) {
+        return '暂未接入'
+      }
+
+      if (table.uploading) {
+        return '上传中'
+      }
+
+      if (table.previewLoading) {
+        return '读取中'
+      }
+
+      if (this.hasMissingFields(table)) {
+        return '需调整'
+      }
+
+      if (table.fileName && this.canUpload(table)) {
+        return '待提交'
+      }
+
+      return this.canUpload(table) ? '可上传' : '待准备'
+    },
+    triggerFileSelect(table) {
+      if (table.uploaded) {
+        return
+      }
+
+      if (!this.isBackendSupported(table)) {
+        this.$message.warning(this.getBlockedReason(table))
+        return
+      }
+
+      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()
+      }
+    },
+    async handleFileChange(table, event) {
+      const file = event.target.files && event.target.files[0]
+      if (!file) {
+        return
+      }
+
+      const isAllowedFile = /\.(csv|xls|xlsx)$/i.test(file.name)
+      if (!isAllowedFile) {
+        this.$message.warning('仅支持 CSV、XLS、XLSX 格式文件')
+        event.target.value = ''
+        return
+      }
+
+      table.fileName = file.name
+      table.fileSize = this.formatFileSize(file.size)
+      table.rawFile = file
+      table.updatedAt = '已选择文件,正在读取内容'
+      table.previewLoading = true
+      table.previewError = ''
+      table.missingFields = []
+      table.sampleRows = []
+      table.recordCount = 0
+
+      event.target.value = ''
+
+      try {
+        const response = await previewPreparedData(table.key, file)
+        const data = response.data || {}
+        table.previewColumns = data.columns && data.columns.length ? data.columns : table.defaultPreviewColumns
+        table.sampleRows = data.sampleRows || []
+        table.recordCount = data.totalRows || 0
+        table.missingFields = data.missingFields || []
+        table.updatedAt = this.hasMissingFields(table) ? '文件内容需要调整' : '已选择文件,待提交'
+
+        if (this.hasMissingFields(table)) {
+          this.$message.warning(`${this.getUploadName(table)}缺少必要内容:${this.formatMissingFields(table)}`)
+        }
+      } catch (error) {
+        table.previewError = error && error.message ? error.message : '文件内容读取失败,请检查文件后重新选择'
+        table.updatedAt = '文件内容读取失败'
+        table.rawFile = null
+      } finally {
+        table.previewLoading = false
+      }
+    },
+    async submitTable(table) {
+      if (!this.isBackendSupported(table)) {
+        this.$message.warning(this.getBlockedReason(table))
+        return
+      }
+
+      if (!this.canUpload(table) && !table.uploaded) {
+        this.$message.warning(this.getBlockedReason(table))
+        return
+      }
+
+      if (!table.rawFile) {
+        this.$message.warning(`请先为${this.getUploadName(table)}选择上传文件`)
+        return
+      }
+
+      if (this.hasMissingFields(table)) {
+        this.$message.warning(`文件缺少必要内容:${this.formatMissingFields(table)}`)
+        return
+      }
+
+      table.uploading = true
+      try {
+        const response = await uploadPreparedData(table.key, table.rawFile)
+        table.uploaded = true
+        table.updatedAt = `${this.getCurrentTime()} 已上传`
+        table.recordCount = (response.data && response.data.totalRows) || table.recordCount
+        this.$message.success((response.data && response.data.message) || `${this.getUploadName(table)}上传成功`)
+
+        const nextTable = this.displayTables.find(item => !item.uploaded && this.canUpload(item))
+        if (nextTable) {
+          this.selectTable(nextTable.key)
+        }
+      } finally {
+        table.uploading = false
+      }
+    },
+    resetFromTable(table) {
+      const dependentKeys = this.getDependentKeys(table)
+
+      this.uploadTables.forEach(item => {
+        if (dependentKeys.has(item.key)) {
+          item.uploaded = false
+          item.fileName = ''
+          item.fileSize = ''
+          item.rawFile = null
+          item.uploading = false
+          item.previewLoading = false
+          item.previewError = ''
+          item.missingFields = []
+          item.sampleRows = []
+          item.recordCount = 0
+          item.previewColumns = item.defaultPreviewColumns
+          item.updatedAt = ''
+        }
+      })
+
+      this.selectTable(table.key)
+      this.$message.info(`已重新打开 ${this.getUploadName(table)} 的上传`)
+    },
+    hasMissingFields(table) {
+      return table && table.missingFields && table.missingFields.length > 0
+    },
+    formatMissingFields(table) {
+      if (!this.hasMissingFields(table)) {
+        return ''
+      }
+      return table.missingFields.map(field => field.label || field.name).join('、')
+    },
+    getPreviewCountText(table) {
+      if (table.previewLoading) {
+        return '读取中'
+      }
+      return table.fileName ? `${table.recordCount || 0} 条真实数据` : '待选择文件'
+    },
+    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;
+  }
+}
+
+.content-row {
+  margin-bottom: 20px;
+}
+
+.overview-card,
+.order-card,
+.detail-card,
+.snapshot-card {
+  border: none;
+}
+
+.overview-card {
+  margin-bottom: 20px;
+}
+
+.hero-card {
+  background: linear-gradient(135deg, #ffffff 0%, #f3f8ff 100%);
+}
+
+.overview-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 24px;
+  margin-bottom: 18px;
+}
+
+.overview-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 8px;
+}
+
+.overview-desc {
+  color: #606266;
+  line-height: 1.7;
+}
+
+.hero-summary {
+  display: flex;
+  gap: 12px;
+  flex-shrink: 0;
+}
+
+.summary-item {
+  min-width: 86px;
+  padding: 12px 14px;
+  border: 1px solid #dcecff;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.82);
+  text-align: center;
+
+  span {
+    display: block;
+    font-size: 24px;
+    font-weight: 700;
+    line-height: 1.2;
+    color: #2563eb;
+  }
+
+  label {
+    display: block;
+    margin-top: 4px;
+    color: #606266;
+    font-size: 12px;
+  }
+}
+
+.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: 8px;
+  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: 8px;
+  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;
+  line-height: 1.5;
+}
+
+.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;
+}
+
+.missing-fields {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 8px;
+}
+
+.upload-dropzone {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 24px;
+  border: 1px dashed #9dccff;
+  border-radius: 8px;
+  background: #f7fbff;
+  color: #409eff;
+  cursor: pointer;
+  margin-bottom: 16px;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #409eff;
+    background: #eef7ff;
+  }
+
+  &.disabled {
+    cursor: not-allowed;
+    color: #909399;
+    background: #f7f7f7;
+    border-color: #dcdfe6;
+  }
+
+  > i {
+    font-size: 34px;
+  }
+
+  strong {
+    display: block;
+    color: #303133;
+    font-size: 16px;
+    margin-bottom: 6px;
+  }
+
+  p {
+    margin: 0;
+    color: #909399;
+    line-height: 1.5;
+  }
+}
+
+.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;
+    align-items: flex-start;
+  }
+
+  .hero-summary {
+    width: 100%;
+  }
+
+  .summary-item {
+    flex: 1;
+  }
+}
+
+@media (max-width: 768px) {
+  .order-title-row,
+  .card-header {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .overview-progress {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .hero-summary {
+    flex-direction: column;
+  }
+
+  .upload-dropzone {
+    align-items: flex-start;
+    padding: 18px;
+  }
+}
+</style>