Jelajahi Sumber

预测前端更新

Yihao Kang 3 bulan lalu
induk
melakukan
b94f450eff

+ 5 - 2
src/api/sales.js

@@ -35,8 +35,11 @@ export function getSalesOverview(params) {
 // 预测销量趋势
 export function predictSalesTrend(params) {
   return request({
-    url: '/statistics/sales/predict',
+    url: 'http://localhost:8085/api/sales/predict',
     method: 'post',
-    data: params
+    data: params,
+    headers: {
+      'Content-Type': 'application/json'
+    }
   })
 }

+ 3 - 3
src/views/lifecycle/overview/index.vue

@@ -258,14 +258,14 @@ export default {
                       file.name.endsWith('.xlsx') ||
                       file.name.endsWith('.xls') ||
                       file.name.endsWith('.csv')
-      const isLt20M = file.size / 1024 / 1024 < 20
+      const isLt300M = file.size / 1024 / 1024 < 300
 
       if (!isExcel) {
         this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
         return false
       }
-      if (!isLt20M) {
-        this.$modal.msgError('上传文件大小不能超过 20MB!')
+      if (!isLt300M) {
+        this.$modal.msgError('上传文件大小不能超过 300MB!')
         return false
       }
       return true

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

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div class="app-container">
     <!-- 页面标题 -->
     <div class="page-header">
@@ -457,14 +457,14 @@ export default {
         file.name.endsWith('.xlsx') ||
         file.name.endsWith('.xls') ||
         file.name.endsWith('.csv')
-      const isLt20M = file.size / 1024 / 1024 < 20
+      const isLt300M = file.size / 1024 / 1024 < 300
 
       if (!isExcel) {
         this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
         return false
       }
-      if (!isLt20M) {
-        this.$modal.msgError('上传文件大小不能超过 20MB!')
+      if (!isLt300M) {
+        this.$modal.msgError('上传文件大小不能超过 300MB!')
         return false
       }
       return true

+ 408 - 20
src/views/sale/overview/index.vue

@@ -35,6 +35,44 @@
       </div>
     </el-card>
 
+    <!-- 视图选择区域 -->
+    <el-card v-if="results.summary" class="mb-20">
+      <div slot="header">
+        <span><i class="el-icon-s-operation"></i> 数据视图选择</span>
+      </div>
+      <div class="view-selector">
+        <el-radio-group v-model="selectedView" @change="handleViewChange">
+          <el-radio-button label="overall">总体概览</el-radio-button>
+          <el-radio-button label="category">按品类查看</el-radio-button>
+          <el-radio-button label="sku">按SKU查看</el-radio-button>
+        </el-radio-group>
+        
+        <!-- 品类选择 -->
+        <el-select v-if="selectedView === 'category' && results.category_list && results.category_list.length > 0" 
+                   v-model="selectedCategory" 
+                   placeholder="选择品类" 
+                   @change="handleCategoryChange"
+                   style="margin-left: 10px; width: 200px;">
+          <el-option v-for="category in results.category_list" 
+                     :key="category" 
+                     :label="category" 
+                     :value="category" />
+        </el-select>
+        
+        <!-- SKU选择 -->
+        <el-select v-if="(selectedView === 'sku' || selectedView === 'category') && getAvailableSkus().length > 0" 
+                   v-model="selectedSku" 
+                   placeholder="选择SKU" 
+                   @change="handleSkuChange"
+                   style="margin-left: 10px; width: 200px;">
+          <el-option v-for="sku in getAvailableSkus()" 
+                     :key="sku" 
+                     :label="sku" 
+                     :value="sku" />
+        </el-select>
+      </div>
+    </el-card>
+
     <!-- 关键指标卡片 -->
     <el-row :gutter="20" class="mb-20">
       <el-col :xs="24" :sm="12" :md="8" :lg="6">
@@ -144,6 +182,58 @@
         </el-card>
       </el-col>
     </el-row>
+
+    <!-- 异常数据详情 -->
+    <el-card v-if="results.anomalies && results.anomalies.anomaly_count > 0" class="mb-20">
+      <div slot="header">
+        <span><i class="el-icon-warning-outline"></i> 异常数据详情</span>
+        <span class="header-desc">共检测到 {{ results.anomalies.anomaly_count }} 个异常,异常率 {{ results.anomalies.anomaly_rate.toFixed(2) }}%</span>
+      </div>
+      <el-table :data="anomalyData" style="width: 100%">
+        <el-table-column prop="date" label="日期" width="120">
+          <template slot-scope="scope">
+            {{ scope.row.date || 'N/A' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="sku" label="SKU" width="180">
+          <template slot-scope="scope">
+            {{ scope.row.sku || 'N/A' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="type" label="异常类型" width="120">
+          <template slot-scope="scope">
+            <el-tag :type="getAnomalyTypeTag(scope.row.type)">
+              {{ getAnomalyTypeName(scope.row.type) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="reason" label="异常原因" min-width="300">
+          <template slot-scope="scope">
+            <span class="anomaly-reason">{{ scope.row.reason }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="value" label="实际值" width="100">
+          <template slot-scope="scope">
+            {{ scope.row.value.toFixed(2) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="expected" label="预期值" width="100">
+          <template slot-scope="scope">
+            {{ scope.row.expected.toFixed(2) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="deviation" label="偏差程度" width="100">
+          <template slot-scope="scope">
+            <el-progress 
+              :percentage="Math.min(scope.row.deviation * 20, 100)" 
+              :color="getDeviationColor(scope.row.deviation)"
+              :stroke-width="10"
+            />
+            <span class="deviation-value">{{ scope.row.deviation.toFixed(2) }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
   </div>
 </template>
 
@@ -178,6 +268,10 @@ export default {
       promotionSeries: [],
       salesSeries: [],
       anomalySeries: [],
+      // 视图选择相关
+      selectedView: 'overall',
+      selectedCategory: '',
+      selectedSku: '',
       // 文件上传相关
       upload: {
         // 是否显示弹出层
@@ -227,14 +321,14 @@ export default {
                       file.name.endsWith('.xlsx') ||
                       file.name.endsWith('.xls') ||
                       file.name.endsWith('.csv')
-      const isLt20M = file.size / 1024 / 1024 < 20
+      const isLt300M = file.size / 1024 / 1024 < 300
 
       if (!isExcel) {
         this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
         return false
       }
-      if (!isLt20M) {
-        this.$modal.msgError('上传文件大小不能超过 20MB!')
+      if (!isLt300M) {
+        this.$modal.msgError('上传文件大小不能超过 300MB!')
         return false
       }
       return true
@@ -245,11 +339,30 @@ export default {
     },
     /** 自定义上传方法 */
     customUpload(options) {
-      const file = options.file
+      console.log('customUpload called')
+      console.log('options:', options)
+      console.log('options.file:', options.file)
+      if (!options.file) {
+        console.error('No file in options')
+        this.$modal.msgError('请选择要上传的文件')
+        options.onError(new Error('No file to upload'))
+        return
+      }
+      console.log('options.file.raw:', options.file.raw)
+      const file = options.file.raw || options.file
+      console.log('file to upload:', file)
+      if (!file) {
+        console.error('No file to upload')
+        this.$modal.msgError('请选择要上传的文件')
+        options.onError(new Error('No file to upload'))
+        return
+      }
+      console.log('Starting file upload to:', '/statistics/sales/upload')
       this.upload.isUploading = true
       uploadAndAnalyzeSales(file).then(response => {
+        console.log('uploadAndAnalyzeSales response:', response)
         this.upload.isUploading = false
-        if (response.code === 200) {
+        if (response && response.code === 200) {
           this.$modal.msgSuccess('文件上传并分析成功')
           // response.data 就是分析结果
           this.results = response.data || {}
@@ -259,12 +372,14 @@ export default {
           })
           options.onSuccess(response)
         } else {
+          console.error('Upload failed with response:', response)
           this.$modal.msgError(response.msg || '分析失败')
           options.onError(new Error(response.msg || '分析失败'))
         }
         // 重置上传组件
         this.$refs.upload.clearFiles()
       }).catch(error => {
+        console.error('uploadAndAnalyzeSales error:', error)
         this.upload.isUploading = false
         const errorMsg = error.response?.data?.msg || error.message || '文件上传失败,请重试'
         this.$modal.msgError(errorMsg)
@@ -302,27 +417,199 @@ export default {
     },
     /** 计算关键指标 */
     calculateMetrics() {
-      // 模拟数据,实际项目中应从results中计算
-      this.totalSales = 12580
-      this.salesGrowthRate = 12.5
-      this.avgPrice = 89.65
-      this.priceChange = -2.3
-      this.avgPromotion = 15.8
-      this.promotionChange = 3.2
-      this.anomalyDetectionRate = 5.7
+      if (!this.results.summary) {
+        return
+      }
       
-      // 模拟趋势数据
-      this.timeSeries = ['1月', '2月', '3月', '4月', '5月', '6月']
-      this.priceSeries = [95.2, 92.8, 90.5, 88.9, 90.2, 89.6]
-      this.promotionSeries = [12.5, 13.2, 14.8, 15.5, 16.2, 15.8]
-      this.salesSeries = [8500, 9200, 10500, 11200, 11800, 12580]
-      this.anomalySeries = [4.2, 3.8, 5.1, 6.5, 7.2, 5.7]
+      // 根据选择的视图计算指标
+      switch (this.selectedView) {
+        case 'overall':
+          this.calculateOverallMetrics()
+          break
+        case 'category':
+          this.calculateCategoryMetrics()
+          break
+        case 'sku':
+          this.calculateSkuMetrics()
+          break
+        default:
+          this.calculateOverallMetrics()
+      }
+    },
+    
+    /** 计算总体指标 */
+    calculateOverallMetrics() {
+      if (this.results.summary) {
+        this.totalSales = this.results.summary.total_quantity || 0
+        this.avgPrice = this.results.summary.total_revenue / this.results.summary.total_quantity || 0
+        
+        // 模拟增长率和变化率数据
+        this.salesGrowthRate = 12.5
+        this.priceChange = -2.3
+        this.avgPromotion = 15.8
+        this.promotionChange = 3.2
+        this.anomalyDetectionRate = 5.7
+        
+        // 模拟趋势数据
+        this.timeSeries = ['1月', '2月', '3月', '4月', '5月', '6月']
+        this.priceSeries = [95.2, 92.8, 90.5, 88.9, 90.2, 89.6]
+        this.promotionSeries = [12.5, 13.2, 14.8, 15.5, 16.2, 15.8]
+        this.salesSeries = [8500, 9200, 10500, 11200, 11800, 12580]
+        this.anomalySeries = [4.2, 3.8, 5.1, 6.5, 7.2, 5.7]
+      }
+    },
+    
+    /** 计算品类指标 */
+    calculateCategoryMetrics() {
+      if (this.selectedCategory && this.results.categories && this.results.categories[this.selectedCategory]) {
+        const categoryData = this.results.categories[this.selectedCategory]
+        this.totalSales = categoryData.total_quantity || 0
+        this.avgPrice = categoryData.avg_price || 0
+        
+        // 模拟增长率和变化率数据
+        this.salesGrowthRate = 15.2
+        this.priceChange = 1.8
+        this.avgPromotion = 18.5
+        this.promotionChange = 2.5
+        this.anomalyDetectionRate = 4.8
+        
+        // 使用品类的趋势数据
+        this.timeSeries = categoryData.date_series || ['1月', '2月', '3月', '4月', '5月', '6月']
+        this.priceSeries = categoryData.price_series || [90.2, 91.5, 92.8, 93.1, 92.5, 91.8]
+        this.salesSeries = categoryData.quantity_series || [7500, 8200, 9100, 9800, 10500, 11200]
+        this.promotionSeries = [16.5, 17.2, 18.1, 19.0, 18.8, 18.5]
+        this.anomalySeries = [3.8, 4.2, 4.5, 5.1, 4.9, 4.8]
+      } else {
+        // 默认选择第一个品类
+        if (this.results.category_list && this.results.category_list.length > 0) {
+          this.selectedCategory = this.results.category_list[0]
+          this.calculateCategoryMetrics()
+        } else {
+          this.calculateOverallMetrics()
+        }
+      }
+    },
+    
+    /** 计算SKU指标 */
+    calculateSkuMetrics() {
+      if (this.selectedSku && this.results.data && this.results.data[this.selectedSku]) {
+        const skuData = this.results.data[this.selectedSku]
+        this.totalSales = skuData.total_quantity || 0
+        this.avgPrice = skuData.avg_price || 0
+        
+        // 模拟增长率和变化率数据
+        this.salesGrowthRate = 22.8
+        this.priceChange = -0.5
+        this.avgPromotion = 22.5
+        this.promotionChange = 3.8
+        this.anomalyDetectionRate = 3.2
+        
+        // 使用SKU的趋势数据
+        this.timeSeries = skuData.date_series || ['1月', '2月', '3月', '4月', '5月', '6月']
+        this.priceSeries = skuData.price_series || [85.2, 84.8, 85.1, 84.9, 84.7, 84.5]
+        this.salesSeries = skuData.quantity_series || [1200, 1350, 1500, 1650, 1800, 1950]
+        this.promotionSeries = [20.5, 21.2, 21.8, 22.5, 23.0, 22.5]
+        this.anomalySeries = [2.8, 3.1, 3.3, 3.5, 3.2, 3.0]
+      } else {
+        // 默认选择第一个SKU
+        if (this.results.sku_list && this.results.sku_list.length > 0) {
+          this.selectedSku = this.results.sku_list[0]
+          this.calculateSkuMetrics()
+        } else {
+          this.calculateOverallMetrics()
+        }
+      }
+    },
+    
+    /** 处理视图变化 */
+    handleViewChange() {
+      this.calculateMetrics()
+      this.renderCharts()
+    },
+    
+    /** 处理品类变化 */
+    handleCategoryChange() {
+      // Reset selected SKU and select first available in new category
+      const availableSkus = this.getAvailableSkus()
+      if (availableSkus.length > 0) {
+        this.selectedSku = availableSkus[0]
+      } else {
+        this.selectedSku = ''
+      }
+      this.calculateMetrics()
+      this.renderCharts()
+    },
+    
+    /** 处理SKU变化 */
+    handleSkuChange() {
+      this.calculateMetrics()
+      this.renderCharts()
+    },
+    
+    /** 获取可用的SKU列表 */
+    getAvailableSkus() {
+      if (this.selectedView === 'category' && this.selectedCategory && this.results.category_skus) {
+        return this.results.category_skus[this.selectedCategory] || []
+      } else {
+        return this.results.sku_list || []
+      }
+    },
+    
+    /** 获取异常类型标签 */
+    getAnomalyTypeTag(type) {
+      switch (type) {
+        case 'quantity_spike':
+        case 'sku_price_spike':
+          return 'warning'
+        case 'quantity_drop':
+        case 'sku_price_drop':
+          return 'danger'
+        case 'price_spike':
+          return 'info'
+        case 'price_drop':
+          return 'success'
+        default:
+          return 'primary'
+      }
+    },
+    
+    /** 获取异常类型名称 */
+    getAnomalyTypeName(type) {
+      switch (type) {
+        case 'quantity_spike':
+          return '销量激增'
+        case 'quantity_drop':
+          return '销量骤降'
+        case 'price_spike':
+          return '价格上涨'
+        case 'price_drop':
+          return '价格下降'
+        case 'sku_price_spike':
+          return 'SKU涨价'
+        case 'sku_price_drop':
+          return 'SKU降价'
+        default:
+          return '异常'
+      }
+    },
+    
+    /** 获取偏差程度颜色 */
+    getDeviationColor(deviation) {
+      if (deviation > 3) {
+        return '#ff4d4f'
+      } else if (deviation > 2.5) {
+        return '#fa8c16'
+      } else if (deviation > 2) {
+        return '#faad14'
+      } else {
+        return '#52c41a'
+      }
     },
     /** 初始化图表 */
     initCharts() {
       if (this.$refs.priceTrendChart) {
         this.priceTrendChart = echarts.init(this.$refs.priceTrendChart, 'macarons')
-      }
+        }
       if (this.$refs.promotionTrendChart) {
         this.promotionTrendChart = echarts.init(this.$refs.promotionTrendChart, 'macarons')
       }
@@ -577,6 +864,15 @@ export default {
         this.anomalyDetectionChart.resize()
       }
     }
+  },
+  computed: {
+    /** 异常数据列表 */
+    anomalyData() {
+      if (this.results.anomalies && this.results.anomalies.anomalies) {
+        return this.results.anomalies.anomalies.sort((a, b) => b.deviation - a.deviation)
+      }
+      return []
+    }
   }
 }
 </script>
@@ -704,4 +1000,96 @@ export default {
     font-weight: normal;
   }
 }
+
+.view-selector {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+  
+  .el-radio-group {
+    display: flex;
+    align-items: center;
+  }
+  
+  .el-select {
+    margin-top: 5px;
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .view-selector {
+    flex-direction: column;
+    align-items: flex-start;
+    
+    .el-select {
+      width: 100% !important;
+      margin-left: 0 !important;
+    }
+  }
+}
+
+/* Anomaly styles */
+.anomaly-reason {
+  line-height: 1.4;
+  color: #303133;
+}
+
+.deviation-value {
+  display: block;
+  text-align: center;
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+
+::v-deep .el-table .cell {
+  padding: 12px 10px;
+}
+
+::v-deep .el-table__row:hover {
+  background-color: #f5f7fa !important;
+}
+
+::v-deep .el-tag {
+  margin-right: 0;
+}
+
+/* Anomaly type tag styles */
+::v-deep .el-tag--warning {
+  background-color: #fff7e6;
+  border-color: #ffd591;
+  color: #fa8c16;
+}
+
+::v-deep .el-tag--danger {
+  background-color: #fff1f0;
+  border-color: #ffccc7;
+  color: #f5222d;
+}
+
+::v-deep .el-tag--info {
+  background-color: #e6f7ff;
+  border-color: #91d5ff;
+  color: #1890ff;
+}
+
+::v-deep .el-tag--success {
+  background-color: #f6ffed;
+  border-color: #b7eb8f;
+  color: #52c41a;
+}
+
+/* Progress bar styles */
+::v-deep .el-progress {
+  margin-bottom: 4px;
+}
+
+::v-deep .el-progress-bar__outer {
+  background-color: #f0f0f0;
+}
+
+::v-deep .el-progress-bar__inner {
+  border-radius: 5px;
+}
 </style>

+ 366 - 157
src/views/sale/trendPred/index.vue

@@ -16,6 +16,8 @@
           :http-request="customUpload"
           :disabled="upload.isUploading"
           :on-change="handleFileChange"
+          :on-success="handleUploadSuccess"
+          :on-error="handleUploadError"
           :before-upload="beforeUpload"
           :auto-upload="false"
           :show-file-list="false"
@@ -30,14 +32,22 @@
       <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>
+          <label class="text-sm font-medium text-gray-700">预测类型:</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"
+                  v-model="predictType">
+            <option value="sku">按SKU预测</option>
+            <option value="category">按类别预测</option>
+          </select>
+        </div>
+        <div class="flex items-center gap-3">
+          <label class="text-sm font-medium text-gray-700">{{ predictType === 'sku' ? '选择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"
+                  v-model="selectedItem">
+            <option v-for="item in selectOptions" :key="item" :value="item">{{ item }}</option>
           </select>
         </div>
         <div class="flex items-center gap-3">
@@ -52,8 +62,8 @@
 
       <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>
+          <p class="text-xs text-gray-500 uppercase tracking-wide">{{ predictType === 'sku' ? 'SKU编码' : '类别名称' }}</p>
+          <p class="text-lg font-medium text-gray-800 truncate">{{ selectedItem }}</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>
@@ -137,6 +147,34 @@
             <p class="text-xs text-gray-400 mt-1">{{ predictedMinSalesDate }}</p>
           </div>
         </div>
+        
+        <!-- a+x+y模型组件 -->
+        <div v-if="axyComponents" class="pt-4 border-t border-gray-200">
+          <h4 class="text-md font-medium text-gray-700 mb-3">a+x+y模型组件</h4>
+          <div class="space-y-3">
+            <div class="flex justify-between">
+              <span class="text-sm text-gray-500">基础值 (a)</span>
+              <span class="text-sm font-medium text-gray-800">{{ axyComponents.base_value.toFixed(2) }}</span>
+            </div>
+            <div class="flex justify-between">
+              <span class="text-sm text-gray-500">趋势因子 (x)</span>
+              <span class="text-sm font-medium" :class="axyComponents.trend_factor >= 0 ? 'text-green-600' : 'text-red-600'">
+                {{ axyComponents.trend_factor >= 0 ? '+' : '' }}{{ (axyComponents.trend_factor * 100).toFixed(2) }}%
+              </span>
+            </div>
+            <div v-if="axyComponents.seasonal_factors && axyComponents.seasonal_factors.length > 0" class="pt-2">
+              <p class="text-xs text-gray-500 uppercase tracking-wide mb-2">季节性因子 (y)</p>
+              <div class="grid grid-cols-7 gap-2">
+                <div v-for="(factor, index) in axyComponents.seasonal_factors" :key="index" class="text-center">
+                  <span class="text-xs text-gray-400">第{{ index+1 }}天</span>
+                  <p class="text-sm font-medium" :class="factor >= 0 ? 'text-green-600' : 'text-red-600'">
+                    {{ factor >= 0 ? '+' : '' }}{{ factor.toFixed(1) }}
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
 
       <!-- 预测详情表格 -->
@@ -151,6 +189,7 @@
               <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">
@@ -164,6 +203,9 @@
               <td class="px-4 py-3 text-sm">
                 <span :class="getTrendClass(item.trend)">{{ item.trend }}</span>
               </td>
+              <td class="px-4 py-3 text-sm">
+                <span class="text-sm font-medium" :class="getConfidenceClass(item.confidence)">{{ (item.confidence * 100).toFixed(1) }}%</span>
+              </td>
             </tr>
             </tbody>
           </table>
@@ -203,7 +245,7 @@
   </div>
 </template>
 
-<script setup>
+<script>
 import { ref, computed, onMounted, beforeUnmount, watch } from 'vue'
 import { uploadAndAnalyzeSales, getSalesResults, predictSalesTrend } from '@/api/sales'
 import { getToken } from '@/utils/auth'
@@ -221,6 +263,8 @@ export default {
         pendingFileName: ''
       },
       predictionPeriod: '7',
+      predictType: 'sku',
+      selectedItem: '',
       results: {}
     }
   },
@@ -228,22 +272,21 @@ export default {
     hasResults() {
       return Object.keys(this.results || {}).length > 0
     },
-    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)
-        }
+    selectOptions() {
+      if (this.predictType === 'sku') {
+        return this.results.sku_list || []
+      } else {
+        return this.results.category_list || []
       }
     },
-    skuOptions() {
-      return Object.keys(this.results || {}).filter(k => k !== '_analysis_summary_')
-    },
     detail() {
-      return (this.results && this.selectedSku && this.results[this.selectedSku]) || null
+      if (!this.results || !this.selectedItem) return null
+      
+      if (this.predictType === 'sku') {
+        return this.results.data && this.results.data[this.selectedItem] || null
+      } else {
+        return this.results.categories && this.results.categories[this.selectedItem] || null
+      }
     },
     totalHistoricalSales() {
       const detail = this.detail || {}
@@ -299,7 +342,17 @@ export default {
     },
     predictionDetails() {
       const detail = this.detail || {}
-      return detail.prediction_details || []
+      const details = detail.prediction_details || []
+      
+      // 为每个预测项添加置信度
+      return details.map(item => ({
+        ...item,
+        confidence: item.confidence || (this.detail && this.detail.prediction && this.detail.prediction.confidence ? this.detail.prediction.confidence : 0.5)
+      }))
+    },
+    axyComponents() {
+      const detail = this.detail || {}
+      return detail.axymodel_components || null
     },
     mape() {
       const detail = this.detail || {}
@@ -352,62 +405,153 @@ export default {
       this.renderSalesTrend()
     },
     results() {
-      if (!this.results || !this.results[this.selectedSku]) {
-        const first = this.skuOptions[0] || ''
-        if (first) this.$store.dispatch('analysis/selectSku', first)
+      if (!this.results || !this.selectedItem) {
+        const first = this.selectOptions[0] || ''
+        if (first) this.selectedItem = first
       }
     },
     predictionPeriod() {
-      if (this.selectedSku) {
+      if (this.selectedItem) {
         this.predictSales()
       }
+    },
+    predictType() {
+      if (this.results) {
+        const first = this.selectOptions[0] || ''
+        if (first) this.selectedItem = first
+        if (this.selectedItem) {
+          this.predictSales()
+        }
+      }
     }
   },
   methods: {
     /** 获取销售分析结果 */
     getList() {
+      console.log('Getting sales results...')
       getSalesResults().then(response => {
+        console.log('Get results response:', response)
         if (response && response.code === 200 && response.data) {
           const results = response.data || {}
+          console.log('Sales results:', results)
           this.results = results
-          const firstSku = this.pickFirstSku(results)
-          if (firstSku) this.$store.dispatch('analysis/selectSku', firstSku)
+          const firstItem = this.selectOptions[0] || ''
+          console.log('First item:', firstItem)
+          if (firstItem) {
+            this.selectedItem = firstItem
+            console.log('Selected first item:', firstItem)
+          }
           this.$nextTick(() => {
+            console.log('Rendering sales trend after getting results...')
             this.renderSalesTrend()
           })
         }
-      }).catch(() => {
+      }).catch(error => {
+        console.error('Error getting sales results:', error)
         this.results = {}
       })
     },
+    /** 刷新数据 */
+    refreshData() {
+      this.getList()
+      this.$modal.msgSuccess('数据刷新成功')
+    },
     /** 预测销量趋势 */
     predictSales() {
-      if (!this.selectedSku) return
+      if (!this.selectedItem) return
       
       const params = {
-        sku: this.selectedSku,
-        period: parseInt(this.predictionPeriod)
+        sku: this.selectedItem,
+        period: parseInt(this.predictionPeriod),
+        predict_type: this.predictType
       }
       
       predictSalesTrend(params).then(response => {
-        if (response && response.code === 200 && response.data) {
+        console.log('Predict response:', response)
+        if (response && response.success && response.data) {
           const results = response.data || {}
-          this.results = results
+          console.log('Prediction results:', results)
+          // 更新results数据以匹配前端期望的结构
+          if (this.predictType === 'sku') {
+            if (!this.results.data) {
+              this.results.data = {}
+            }
+            this.results.data[this.selectedItem] = {
+              historical_sales: results.historical_data && results.historical_data.quantities ? results.historical_data.quantities : [],
+              predicted_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities : [],
+              date_series: results.historical_data && results.historical_data.dates ? results.historical_data.dates : [],
+              predicted_total_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities.reduce((a, b) => a + b, 0) : 0,
+              predicted_average_daily_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : 0,
+              predicted_max_sales: results.prediction && results.prediction.quantities ? Math.max(...results.prediction.quantities) : 0,
+              predicted_min_sales: results.prediction && results.prediction.quantities ? Math.min(...results.prediction.quantities) : 0,
+              predicted_max_sales_date: results.prediction && results.prediction.dates && results.prediction.quantities ? results.prediction.dates[results.prediction.quantities.indexOf(Math.max(...results.prediction.quantities))] : null,
+              predicted_min_sales_date: results.prediction && results.prediction.dates && results.prediction.quantities ? results.prediction.dates[results.prediction.quantities.indexOf(Math.min(...results.prediction.quantities))] : null,
+              prediction_accuracy: results.model_evaluation && results.model_evaluation.model_accuracy ? results.model_evaluation.model_accuracy + '%' : '0%',
+              mape: results.model_evaluation && results.model_evaluation.mape ? results.model_evaluation.mape : '0%',
+              rmse: results.model_evaluation && results.model_evaluation.rmse ? results.model_evaluation.rmse : 0,
+              mae: results.model_evaluation && results.model_evaluation.mae ? results.model_evaluation.mae : 0,
+              r_squared: results.model_evaluation && results.model_evaluation.r_squared ? results.model_evaluation.r_squared : 0,
+              model_evaluation: results.model_evaluation || {},
+              axymodel_components: results.axymodel_components || {}
+            }
+          } else {
+            if (!this.results.categories) {
+              this.results.categories = {}
+            }
+            this.results.categories[this.selectedItem] = {
+              historical_sales: results.historical_data && results.historical_data.quantities ? results.historical_data.quantities : [],
+              predicted_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities : [],
+              date_series: results.historical_data && results.historical_data.dates ? results.historical_data.dates : [],
+              predicted_total_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities.reduce((a, b) => a + b, 0) : 0,
+              predicted_average_daily_sales: results.prediction && results.prediction.quantities ? results.prediction.quantities.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : 0,
+              predicted_max_sales: results.prediction && results.prediction.quantities ? Math.max(...results.prediction.quantities) : 0,
+              predicted_min_sales: results.prediction && results.prediction.quantities ? Math.min(...results.prediction.quantities) : 0,
+              predicted_max_sales_date: results.prediction && results.prediction.dates && results.prediction.quantities ? results.prediction.dates[results.prediction.quantities.indexOf(Math.max(...results.prediction.quantities))] : null,
+              predicted_min_sales_date: results.prediction && results.prediction.dates && results.prediction.quantities ? results.prediction.dates[results.prediction.quantities.indexOf(Math.min(...results.prediction.quantities))] : null,
+              prediction_accuracy: results.model_evaluation && results.model_evaluation.model_accuracy ? results.model_evaluation.model_accuracy + '%' : '0%',
+              mape: results.model_evaluation && results.model_evaluation.mape ? results.model_evaluation.mape : '0%',
+              rmse: results.model_evaluation && results.model_evaluation.rmse ? results.model_evaluation.rmse : 0,
+              mae: results.model_evaluation && results.model_evaluation.mae ? results.model_evaluation.mae : 0,
+              r_squared: results.model_evaluation && results.model_evaluation.r_squared ? results.model_evaluation.r_squared : 0,
+              model_evaluation: results.model_evaluation || {},
+              axymodel_components: results.axymodel_components || {}
+            }
+          }
           this.$nextTick(() => {
             this.renderSalesTrend()
           })
+          this.$modal.msgSuccess(`预测成功!模型准确率:${results.model_evaluation && results.model_evaluation.model_accuracy ? results.model_evaluation.model_accuracy : 0}%`)
+        } else if (response && !response.success) {
+          this.$modal.msgError(response.message || '预测失败,请重试')
+        } else {
+          this.$modal.msgError('预测失败,请重试')
         }
-      }).catch(() => {
-        this.$modal.msgError('预测失败,请重试')
+      }).catch(error => {
+        console.error('预测失败:', error)
+        this.$modal.msgError('预测失败,请检查服务是否正常运行')
       })
     },
     /** 文件选择改变处理 */
     handleFileChange(file, fileList) {
+      console.log('handleFileChange called')
+      console.log('file:', file)
+      console.log('fileList:', fileList)
       if (!fileList || fileList.length === 0) return
       if (!file || !file.raw) return
 
       this.upload.pendingFileName = file.name
       this.upload.fileName = ''
+      console.log('pendingFileName set to:', this.upload.pendingFileName)
+    },
+    handleUploadSuccess(response, file, fileList) {
+      console.log('handleUploadSuccess called')
+      console.log('response:', response)
+      console.log('file:', file)
+    },
+    handleUploadError(error, file, fileList) {
+      console.error('handleUploadError called')
+      console.error('error:', error)
+      console.error('file:', file)
     },
     /** 文件上传前的校验 */
     beforeUpload(file) {
@@ -417,40 +561,67 @@ export default {
         file.name.endsWith('.xlsx') ||
         file.name.endsWith('.xls') ||
         file.name.endsWith('.csv')
-      const isLt20M = file.size / 1024 / 1024 < 20
+      const isLt300M = file.size / 1024 / 1024 < 300
 
       if (!isExcel) {
         this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
         return false
       }
-      if (!isLt20M) {
-        this.$modal.msgError('上传文件大小不能超过 20MB!')
+      if (!isLt300M) {
+        this.$modal.msgError('上传文件大小不能超过 300MB!')
         return false
       }
       return true
     },
     customUpload(options) {
-      const file = options.file
+      console.log('customUpload called')
+      console.log('options:', options)
+      console.log('options.file:', options.file)
+      if (!options.file) {
+        console.error('No file in options')
+        this.$modal.msgError('请选择要上传的文件')
+        options.onError(new Error('No file to upload'))
+        return
+      }
+      console.log('options.file.raw:', options.file.raw)
+      const file = options.file.raw || options.file
+      console.log('file to upload:', file)
+      if (!file) {
+        console.error('No file to upload')
+        this.$modal.msgError('请选择要上传的文件')
+        options.onError(new Error('No file to upload'))
+        return
+      }
+      console.log('Starting file upload to:', '/statistics/sales/upload')
       this.upload.isUploading = true
       uploadAndAnalyzeSales(file).then(response => {
+        console.log('uploadAndAnalyzeSales response:', response)
         this.upload.isUploading = false
         if (response && response.code === 200) {
           const results = response.data || {}
+          console.log('Upload analysis results:', results)
           this.results = results
-          const firstSku = this.pickFirstSku(results)
-          if (firstSku) this.$store.dispatch('analysis/selectSku', firstSku)
+          const firstItem = this.selectOptions[0] || ''
+          if (firstItem) {
+            this.selectedItem = firstItem
+            console.log('Selected first item:', firstItem)
+            console.log('Results data structure:', this.results)
+          }
           this.$modal.msgSuccess('文件上传并分析成功')
           this.upload.fileName = this.upload.pendingFileName || file.name
           this.upload.pendingFileName = ''
           this.$nextTick(() => {
+            console.log('Rendering sales trend...')
             this.renderSalesTrend()
           })
           options.onSuccess(response)
         } else {
+          console.error('Upload failed with response:', response)
           this.$modal.msgError(response.msg || '分析失败')
           options.onError(new Error(response.msg || '分析失败'))
         }
       }).catch(error => {
+        console.error('uploadAndAnalyzeSales error:', error)
         this.upload.isUploading = false
         const msg = (error && error.message) || '文件上传失败,请重试'
         this.$modal.msgError(msg)
@@ -462,152 +633,183 @@ export default {
       })
     },
     submitUpload() {
+      console.log('submitUpload called')
       const target = this.$refs.toolbarUpload
-      const fileList = target && target.uploadFiles ? target.uploadFiles : []
+      console.log('target:', target)
+      if (!target) {
+        console.error('No upload component reference')
+        this.$modal.msgError('上传组件未初始化')
+        return
+      }
+      const fileList = target.uploadFiles || []
+      console.log('fileList:', fileList)
       if (!fileList || fileList.length === 0) {
+        console.error('No files selected')
         this.$modal.msgError('请选择要上传的文件')
         return
       }
+      console.log('Calling target.submit()')
       target.submit()
     },
     /** 渲染销量趋势与预测图 */
     renderSalesTrend() {
+      console.log('Rendering sales trend...')
       const canvas = this.$refs.salesTrendRef
-      if (!canvas) return
+      if (!canvas) {
+        console.error('No canvas reference found')
+        return
+      }
       
+      console.log('Detail data:', this.detail)
       const detail = this.detail || {}
       const historicalSales = detail.historical_sales || []
       const predictedSales = detail.predicted_sales || []
       const dates = detail.date_series || []
+      
+      console.log('Historical sales:', historicalSales)
+      console.log('Predicted sales:', predictedSales)
+      console.log('Dates:', dates)
+      
+      // 生成标签
       const labels = dates.map(d => formatDate(d))
+      console.log('Labels:', labels)
       
       // 计算趋势线数据
       const trendLineData = this.calculateTrendLine(historicalSales)
+      console.log('Trend line data:', trendLineData)
       
       // 合并历史和预测数据
       const combinedSales = [...historicalSales, ...predictedSales]
       const combinedLabels = [...labels, ...this.generatePredictionDates(parseInt(this.predictionPeriod))]
       
+      console.log('Combined sales:', combinedSales)
+      console.log('Combined labels:', combinedLabels)
+      
       if (this.salesTrendChart) this.salesTrendChart.destroy()
       
-      this.salesTrendChart = new Chart(canvas, {
-        type: 'line',
-        data: {
-          labels: combinedLabels,
-          datasets: [
-            {
-              label: '历史销量',
-              data: [...historicalSales, ...Array(predictedSales.length).fill(null)],
-              borderColor: '#3b82f6',
-              backgroundColor: 'rgba(59,130,246,0.15)',
-              lineTension: 0.25,
-              pointRadius: 0,
-              pointHoverRadius: 6,
-              pointHoverBackgroundColor: '#3b82f6',
-              pointHoverBorderColor: '#ffffff',
-              pointHoverBorderWidth: 2
-            },
-            {
-              label: '预测销量',
-              data: [...Array(historicalSales.length).fill(null), ...predictedSales],
-              borderColor: '#10b981',
-              backgroundColor: 'rgba(16,185,129,0.15)',
-              borderDash: [5, 5],
-              lineTension: 0.25,
-              pointRadius: 0,
-              pointHoverRadius: 6,
-              pointHoverBackgroundColor: '#10b981',
-              pointHoverBorderColor: '#ffffff',
-              pointHoverBorderWidth: 2
-            },
-            {
-              label: '趋势线',
-              data: [...trendLineData, ...Array(predictedSales.length).fill(null)],
-              borderColor: '#8b5cf6',
-              backgroundColor: 'transparent',
-              lineTension: 0.1,
-              pointRadius: 0,
-              pointHoverRadius: 0
-            }
-          ]
-        },
-        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
+      try {
+        this.salesTrendChart = new Chart(canvas, {
+          type: 'line',
+          data: {
+            labels: combinedLabels,
+            datasets: [
+              {
+                label: '历史销量',
+                data: [...historicalSales, ...Array(predictedSales.length).fill(null)],
+                borderColor: '#3b82f6',
+                backgroundColor: 'rgba(59,130,246,0.15)',
+                lineTension: 0.25,
+                pointRadius: 0,
+                pointHoverRadius: 6,
+                pointHoverBackgroundColor: '#3b82f6',
+                pointHoverBorderColor: '#ffffff',
+                pointHoverBorderWidth: 2
+              },
+              {
+                label: '预测销量',
+                data: [...Array(historicalSales.length).fill(null), ...predictedSales],
+                borderColor: '#10b981',
+                backgroundColor: 'rgba(16,185,129,0.15)',
+                borderDash: [5, 5],
+                lineTension: 0.25,
+                pointRadius: 0,
+                pointHoverRadius: 6,
+                pointHoverBackgroundColor: '#10b981',
+                pointHoverBorderColor: '#ffffff',
+                pointHoverBorderWidth: 2
               },
-              label: function(context, data) {
-                const datasetLabel = data.datasets[context.datasetIndex].label
-                const value = context.value
-                return datasetLabel + ': ' + Number(value).toLocaleString()
+              {
+                label: '趋势线',
+                data: [...trendLineData, ...Array(predictedSales.length).fill(null)],
+                borderColor: '#8b5cf6',
+                backgroundColor: 'transparent',
+                lineTension: 0.1,
+                pointRadius: 0,
+                pointHoverRadius: 0
               }
-            }
-          },
-          hover: {
-            mode: 'index',
-            intersect: false
+            ]
           },
-          scales: {
-            xAxes: [{
-              ticks: {
-                maxRotation: 0,
-                autoSkip: true,
-                maxTicksLimit: 12,
-                callback: function(value, index) {
-                  return combinedLabels[index]
+          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()
                 }
               }
-            }],
-            yAxes: [{
-              ticks: {
-                beginAtZero: true
-              }
-            }]
-          }
-        },
-        plugins: [{
-          id: 'prediction-area',
-          beforeDatasetsDraw(chart) {
-            const ctx = chart.ctx
-            const chartArea = chart.chartArea
-            const xScale = chart.scales['x-axis-0']
-            
-            if (historicalSales.length === 0 || predictedSales.length === 0) return
-            
-            const predictionStartIndex = historicalSales.length - 1
-            const x1 = xScale.getPixelForValue(predictionStartIndex)
-            
-            ctx.save()
-            ctx.fillStyle = 'rgba(16,185,129,0.05)'
-            ctx.fillRect(x1, chartArea.top, chartArea.right - x1, chartArea.bottom - chartArea.top)
-            
-            ctx.strokeStyle = 'rgba(16,185,129,0.3)'
-            ctx.setLineDash([5, 5])
-            ctx.lineWidth = 1
-            ctx.beginPath()
-            ctx.moveTo(x1, chartArea.top)
-            ctx.lineTo(x1, chartArea.bottom)
-            ctx.stroke()
-            
-            ctx.fillStyle = 'rgba(16,185,129,0.8)'
-            ctx.font = 'bold 12px sans-serif'
-            ctx.textAlign = 'left'
-            ctx.fillText('预测区域', x1 + 10, chartArea.top + 20)
-            ctx.restore()
-          }
-        }]
-      })
+            },
+            hover: {
+              mode: 'index',
+              intersect: false
+            },
+            scales: {
+              xAxes: [{
+                ticks: {
+                  maxRotation: 0,
+                  autoSkip: true,
+                  maxTicksLimit: 12,
+                  callback: function(value, index) {
+                    return combinedLabels[index]
+                  }
+                }
+              }],
+              yAxes: [{
+                ticks: {
+                  beginAtZero: true
+                }
+              }]
+            }
+          },
+          plugins: [{
+            id: 'prediction-area',
+            beforeDatasetsDraw(chart) {
+              const ctx = chart.ctx
+              const chartArea = chart.chartArea
+              const xScale = chart.scales['x-axis-0']
+              
+              if (historicalSales.length === 0 || predictedSales.length === 0) return
+              
+              const predictionStartIndex = historicalSales.length - 1
+              const x1 = xScale.getPixelForValue(predictionStartIndex)
+              
+              ctx.save()
+              ctx.fillStyle = 'rgba(16,185,129,0.05)'
+              ctx.fillRect(x1, chartArea.top, chartArea.right - x1, chartArea.bottom - chartArea.top)
+              
+              ctx.strokeStyle = 'rgba(16,185,129,0.3)'
+              ctx.setLineDash([5, 5])
+              ctx.lineWidth = 1
+              ctx.beginPath()
+              ctx.moveTo(x1, chartArea.top)
+              ctx.lineTo(x1, chartArea.bottom)
+              ctx.stroke()
+              
+              ctx.fillStyle = 'rgba(16,185,129,0.8)'
+              ctx.font = 'bold 12px sans-serif'
+              ctx.textAlign = 'left'
+              ctx.fillText('预测区域', x1 + 10, chartArea.top + 20)
+              ctx.restore()
+            }
+          }]
+        })
+        console.log('Chart rendered successfully')
+      } catch (error) {
+        console.error('Error rendering chart:', error)
+      }
     },
     /** 计算趋势线 */
     calculateTrendLine(data) {
@@ -648,6 +850,13 @@ export default {
       if (trend === '下降') return 'text-red-600 font-medium'
       return 'text-gray-600 font-medium'
     },
+    /** 获取置信度样式类 */
+    getConfidenceClass(confidence) {
+      const confidenceValue = typeof confidence === 'number' ? confidence : parseFloat(confidence)
+      if (confidenceValue >= 0.8) return 'text-green-600'
+      if (confidenceValue >= 0.6) return 'text-yellow-600'
+      return 'text-red-600'
+    },
     /** 导出预测结果 */
     exportResults() {
       if (!this.hasResults) {