|
|
@@ -0,0 +1,852 @@
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <!-- 页面标题 -->
|
|
|
+ <div class="page-header">
|
|
|
+ <h2><i class="el-icon-data-line"></i> 销量趋势预测</h2>
|
|
|
+ <p class="page-desc">基于历史销量数据,预测未来一段时间的销量趋势,包含详细的预测指标和模型评估</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="upload-toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <el-upload
|
|
|
+ ref="toolbarUpload"
|
|
|
+ class="toolbar-upload"
|
|
|
+ :limit="1"
|
|
|
+ accept=".xlsx,.xls,.csv"
|
|
|
+ :http-request="customUpload"
|
|
|
+ :disabled="upload.isUploading"
|
|
|
+ :on-change="handleFileChange"
|
|
|
+ :before-upload="beforeUpload"
|
|
|
+ :auto-upload="false"
|
|
|
+ :show-file-list="false"
|
|
|
+ >
|
|
|
+ <el-button plain>上传文件</el-button>
|
|
|
+ </el-upload>
|
|
|
+ <el-button :loading="upload.isUploading" type="primary" @click="submitUpload">开始预测</el-button>
|
|
|
+ <el-button type="success" :disabled="!hasResults" @click="exportResults">导出预测</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-status" v-if="upload.fileName">已上传:{{ upload.fileName }}</div>
|
|
|
+ <div class="toolbar-status" v-else-if="upload.pendingFileName">已选择:{{ upload.pendingFileName }}</div>
|
|
|
+ <div class="toolbar-status muted" v-else>未上传</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- SKU选择 + 预测周期选择 + 基础信息板块 -->
|
|
|
+ <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <label class="text-sm font-medium text-gray-700">选择SKU:</label>
|
|
|
+ <select class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm sku-select"
|
|
|
+ v-model="selectedSku">
|
|
|
+ <option v-for="k in skuOptions" :key="k" :value="k">{{ k }}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <label class="text-sm font-medium text-gray-700">预测周期:</label>
|
|
|
+ <el-radio-group v-model="predictionPeriod" size="small">
|
|
|
+ <el-radio-button label="7">7天</el-radio-button>
|
|
|
+ <el-radio-button label="14">14天</el-radio-button>
|
|
|
+ <el-radio-button label="30">30天</el-radio-button>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">SKU编码</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800 truncate">{{ selectedSku }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">历史总销量</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ totalHistoricalSales }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">平均日销量</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ averageDailySales }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">预测准确率</p>
|
|
|
+ <p class="text-lg font-medium" :class="predictionAccuracyClass">{{ predictionAccuracy }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 销量趋势与预测图 -->
|
|
|
+ <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <div class="flex justify-between items-center mb-6">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800">销量趋势与预测</h3>
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="w-3 h-3 rounded-full bg-blue-500"></span>
|
|
|
+ <span class="text-sm text-gray-600">历史销量</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="w-3 h-3 rounded-full bg-green-500"></span>
|
|
|
+ <span class="text-sm text-gray-600">预测销量</span>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="w-3 h-3 rounded-full bg-purple-500"></span>
|
|
|
+ <span class="text-sm text-gray-600">趋势线</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="h-96">
|
|
|
+ <canvas ref="salesTrendRef"></canvas>
|
|
|
+ </div>
|
|
|
+ <div class="mt-4 space-y-2">
|
|
|
+ <div v-if="hasResults" class="text-sm">
|
|
|
+ <span class="text-green-700 font-medium">
|
|
|
+ <i class="fa fa-check-circle mr-1"></i>
|
|
|
+ 预测完成:基于历史数据预测未来{{ predictionPeriod }}天的销量趋势
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="text-sm text-gray-500">
|
|
|
+ <i class="fa fa-info-circle mr-1"></i>
|
|
|
+ 请上传历史销量数据并点击"开始预测"按钮生成预测结果
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 预测指标与详情分组 -->
|
|
|
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-20">
|
|
|
+ <!-- 预测指标板块 -->
|
|
|
+ <div class="bg-white rounded-xl p-6 shadow-sm space-y-6">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800">预测指标</h3>
|
|
|
+ <div>
|
|
|
+ <div class="flex justify-between mb-2">
|
|
|
+ <span class="text-sm text-gray-500">预测期总销量</span>
|
|
|
+ <span class="text-sm font-medium text-gray-800">{{ predictedTotalSales }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="progress-bar"><div class="progress-value bg-blue-500" :style="{ width: predictedTotalSalesPct+'%' }"></div></div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="flex justify-between mb-2">
|
|
|
+ <span class="text-sm text-gray-500">预测期平均日销量</span>
|
|
|
+ <span class="text-sm font-medium text-gray-800">{{ predictedAverageDailySales }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="progress-bar"><div class="progress-value bg-green-500" :style="{ width: predictedAverageDailySalesPct+'%' }"></div></div>
|
|
|
+ </div>
|
|
|
+ <div class="grid grid-cols-2 gap-4 pt-2">
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">预测最高销量</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ predictedMaxSales }}</p>
|
|
|
+ <p class="text-xs text-gray-400 mt-1">{{ predictedMaxSalesDate }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">预测最低销量</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ predictedMinSales }}</p>
|
|
|
+ <p class="text-xs text-gray-400 mt-1">{{ predictedMinSalesDate }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 预测详情表格 -->
|
|
|
+ <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800 mb-6">预测详情</h3>
|
|
|
+ <div class="overflow-x-auto">
|
|
|
+ <table class="min-w-full divide-y divide-gray-200">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日期</th>
|
|
|
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">历史销量</th>
|
|
|
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">预测销量</th>
|
|
|
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">偏差率</th>
|
|
|
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">趋势</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody class="bg-white divide-y divide-gray-200">
|
|
|
+ <tr v-for="(item, index) in predictionDetails" :key="index">
|
|
|
+ <td class="px-4 py-3 text-sm">{{ item.date }}</td>
|
|
|
+ <td class="px-4 py-3 text-sm font-medium text-gray-800">{{ item.historicalSales }}</td>
|
|
|
+ <td class="px-4 py-3 text-sm font-medium text-green-700">{{ item.predictedSales }}</td>
|
|
|
+ <td class="px-4 py-3 text-sm" :class="item.deviationRate >= 0 ? 'text-red-600' : 'text-green-600'">
|
|
|
+ {{ item.deviationRate >= 0 ? '+' : '' }}{{ item.deviationRate }}%
|
|
|
+ </td>
|
|
|
+ <td class="px-4 py-3 text-sm">
|
|
|
+ <span :class="getTrendClass(item.trend)">{{ item.trend }}</span>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 模型评估模块 -->
|
|
|
+ <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <div class="flex justify-between items-center mb-6">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800">模型评估</h3>
|
|
|
+ <div class="text-sm" :class="modelAccuracyClass">
|
|
|
+ 模型准确率 {{ modelAccuracy }}%
|
|
|
+ <span class="ml-2">{{ modelAccuracyLevel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">平均绝对百分比误差 (MAPE)</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ mape }}</p>
|
|
|
+ <p class="text-xs text-gray-400 mt-1">{{ mapeLevel }}</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">均方根误差 (RMSE)</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ rmse }}</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">平均绝对误差 (MAE)</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ mae }}</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">R² 决定系数</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ rSquared }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onMounted, beforeUnmount, watch } from 'vue'
|
|
|
+import { uploadAndAnalyzeSales, getSalesResults, predictSalesTrend } from '@/api/sales'
|
|
|
+import { getToken } from '@/utils/auth'
|
|
|
+import { Chart } from 'chart.js'
|
|
|
+import { formatDate } from '../../../utils/format'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'SalesTrendPrediction',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ salesTrendChart: null,
|
|
|
+ upload: {
|
|
|
+ isUploading: false,
|
|
|
+ fileName: '',
|
|
|
+ pendingFileName: ''
|
|
|
+ },
|
|
|
+ predictionPeriod: '7',
|
|
|
+ results: {}
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ skuOptions() {
|
|
|
+ return Object.keys(this.results || {}).filter(k => k !== '_analysis_summary_')
|
|
|
+ },
|
|
|
+ detail() {
|
|
|
+ return (this.results && this.selectedSku && this.results[this.selectedSku]) || null
|
|
|
+ },
|
|
|
+ totalHistoricalSales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.total_historical_sales || 0
|
|
|
+ },
|
|
|
+ averageDailySales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.average_daily_sales || 0
|
|
|
+ },
|
|
|
+ predictionAccuracy() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.prediction_accuracy || '0%'
|
|
|
+ },
|
|
|
+ predictionAccuracyClass() {
|
|
|
+ const accuracy = parseFloat(this.predictionAccuracy) || 0
|
|
|
+ if (accuracy >= 80) return 'text-green-700'
|
|
|
+ if (accuracy >= 60) return 'text-yellow-600'
|
|
|
+ return 'text-red-600'
|
|
|
+ },
|
|
|
+ predictedTotalSales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_total_sales || 0
|
|
|
+ },
|
|
|
+ predictedTotalSalesPct() {
|
|
|
+ const total = this.totalHistoricalSales || 1
|
|
|
+ const predicted = this.predictedTotalSales || 0
|
|
|
+ return Math.min(100, (predicted / total) * 100)
|
|
|
+ },
|
|
|
+ predictedAverageDailySales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_average_daily_sales || 0
|
|
|
+ },
|
|
|
+ predictedAverageDailySalesPct() {
|
|
|
+ const avg = this.averageDailySales || 1
|
|
|
+ const predictedAvg = this.predictedAverageDailySales || 0
|
|
|
+ return Math.min(100, (predictedAvg / avg) * 100)
|
|
|
+ },
|
|
|
+ predictedMaxSales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_max_sales || 0
|
|
|
+ },
|
|
|
+ predictedMaxSalesDate() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_max_sales_date ? formatDate(detail.predicted_max_sales_date) : '-'
|
|
|
+ },
|
|
|
+ predictedMinSales() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_min_sales || 0
|
|
|
+ },
|
|
|
+ predictedMinSalesDate() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.predicted_min_sales_date ? formatDate(detail.predicted_min_sales_date) : '-'
|
|
|
+ },
|
|
|
+ predictionDetails() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.prediction_details || []
|
|
|
+ },
|
|
|
+ mape() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.mape || '0%'
|
|
|
+ },
|
|
|
+ mapeLevel() {
|
|
|
+ const mapeValue = parseFloat(this.mape) || 0
|
|
|
+ if (mapeValue < 10) return '优秀'
|
|
|
+ if (mapeValue < 20) return '良好'
|
|
|
+ if (mapeValue < 30) return '一般'
|
|
|
+ return '需改进'
|
|
|
+ },
|
|
|
+ rmse() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.rmse || 0
|
|
|
+ },
|
|
|
+ mae() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.mae || 0
|
|
|
+ },
|
|
|
+ rSquared() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.r_squared || 0
|
|
|
+ },
|
|
|
+ modelAccuracy() {
|
|
|
+ const detail = this.detail || {}
|
|
|
+ return detail.model_accuracy || 0
|
|
|
+ },
|
|
|
+ modelAccuracyClass() {
|
|
|
+ const accuracy = parseFloat(this.modelAccuracy) || 0
|
|
|
+ if (accuracy >= 80) return 'text-green-600'
|
|
|
+ if (accuracy >= 60) return 'text-yellow-600'
|
|
|
+ return 'text-red-600'
|
|
|
+ },
|
|
|
+ modelAccuracyLevel() {
|
|
|
+ const accuracy = parseFloat(this.modelAccuracy) || 0
|
|
|
+ if (accuracy >= 80) return '(优秀)'
|
|
|
+ if (accuracy >= 60) return '(良好)'
|
|
|
+ return '(需改进)'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.getList()
|
|
|
+ },
|
|
|
+ beforeUnmount() {
|
|
|
+ if (this.salesTrendChart) this.salesTrendChart.destroy()
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ detail() {
|
|
|
+ this.renderSalesTrend()
|
|
|
+ },
|
|
|
+ results() {
|
|
|
+ if (!this.results || !this.results[this.selectedSku]) {
|
|
|
+ const first = this.skuOptions[0] || ''
|
|
|
+ if (first) this.$store.dispatch('analysis/selectSku', first)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ predictionPeriod() {
|
|
|
+ if (this.selectedSku) {
|
|
|
+ this.predictSales()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ /** 获取销售分析结果 */
|
|
|
+ getList() {
|
|
|
+ getSalesResults().then(response => {
|
|
|
+ if (response && response.code === 200 && response.data) {
|
|
|
+ const results = response.data || {}
|
|
|
+ this.results = results
|
|
|
+ const firstSku = this.pickFirstSku(results)
|
|
|
+ if (firstSku) this.$store.dispatch('analysis/selectSku', firstSku)
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.renderSalesTrend()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ this.results = {}
|
|
|
+ })
|
|
|
+ },
|
|
|
+ /** 预测销量趋势 */
|
|
|
+ predictSales() {
|
|
|
+ if (!this.selectedSku) return
|
|
|
+
|
|
|
+ const params = {
|
|
|
+ sku: this.selectedSku,
|
|
|
+ period: parseInt(this.predictionPeriod)
|
|
|
+ }
|
|
|
+
|
|
|
+ predictSalesTrend(params).then(response => {
|
|
|
+ if (response && response.code === 200 && response.data) {
|
|
|
+ const results = response.data || {}
|
|
|
+ this.results = results
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.renderSalesTrend()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ this.$modal.msgError('预测失败,请重试')
|
|
|
+ })
|
|
|
+ },
|
|
|
+ /** 文件选择改变处理 */
|
|
|
+ handleFileChange(file, fileList) {
|
|
|
+ if (!fileList || fileList.length === 0) return
|
|
|
+ if (!file || !file.raw) return
|
|
|
+
|
|
|
+ this.upload.pendingFileName = file.name
|
|
|
+ this.upload.fileName = ''
|
|
|
+ },
|
|
|
+ /** 文件上传前的校验 */
|
|
|
+ beforeUpload(file) {
|
|
|
+ const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
|
|
+ file.type === 'application/vnd.ms-excel' ||
|
|
|
+ file.type === 'text/csv' ||
|
|
|
+ file.name.endsWith('.xlsx') ||
|
|
|
+ file.name.endsWith('.xls') ||
|
|
|
+ file.name.endsWith('.csv')
|
|
|
+ const isLt20M = file.size / 1024 / 1024 < 20
|
|
|
+
|
|
|
+ if (!isExcel) {
|
|
|
+ this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (!isLt20M) {
|
|
|
+ this.$modal.msgError('上传文件大小不能超过 20MB!')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ },
|
|
|
+ customUpload(options) {
|
|
|
+ const file = options.file
|
|
|
+ this.upload.isUploading = true
|
|
|
+ uploadAndAnalyzeSales(file).then(response => {
|
|
|
+ this.upload.isUploading = false
|
|
|
+ if (response && response.code === 200) {
|
|
|
+ const results = response.data || {}
|
|
|
+ this.results = results
|
|
|
+ const firstSku = this.pickFirstSku(results)
|
|
|
+ if (firstSku) this.$store.dispatch('analysis/selectSku', firstSku)
|
|
|
+ this.$modal.msgSuccess('文件上传并分析成功')
|
|
|
+ this.upload.fileName = this.upload.pendingFileName || file.name
|
|
|
+ this.upload.pendingFileName = ''
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.renderSalesTrend()
|
|
|
+ })
|
|
|
+ options.onSuccess(response)
|
|
|
+ } else {
|
|
|
+ this.$modal.msgError(response.msg || '分析失败')
|
|
|
+ options.onError(new Error(response.msg || '分析失败'))
|
|
|
+ }
|
|
|
+ }).catch(error => {
|
|
|
+ this.upload.isUploading = false
|
|
|
+ const msg = (error && error.message) || '文件上传失败,请重试'
|
|
|
+ this.$modal.msgError(msg)
|
|
|
+ options.onError(error)
|
|
|
+ }).finally(() => {
|
|
|
+ if (this.$refs.toolbarUpload) {
|
|
|
+ this.$refs.toolbarUpload.clearFiles()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ submitUpload() {
|
|
|
+ const target = this.$refs.toolbarUpload
|
|
|
+ const fileList = target && target.uploadFiles ? target.uploadFiles : []
|
|
|
+ if (!fileList || fileList.length === 0) {
|
|
|
+ this.$modal.msgError('请选择要上传的文件')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ target.submit()
|
|
|
+ },
|
|
|
+ /** 渲染销量趋势与预测图 */
|
|
|
+ renderSalesTrend() {
|
|
|
+ const canvas = this.$refs.salesTrendRef
|
|
|
+ if (!canvas) return
|
|
|
+
|
|
|
+ const detail = this.detail || {}
|
|
|
+ const historicalSales = detail.historical_sales || []
|
|
|
+ const predictedSales = detail.predicted_sales || []
|
|
|
+ const dates = detail.date_series || []
|
|
|
+ const labels = dates.map(d => formatDate(d))
|
|
|
+
|
|
|
+ // 计算趋势线数据
|
|
|
+ const trendLineData = this.calculateTrendLine(historicalSales)
|
|
|
+
|
|
|
+ // 合并历史和预测数据
|
|
|
+ const combinedSales = [...historicalSales, ...predictedSales]
|
|
|
+ const combinedLabels = [...labels, ...this.generatePredictionDates(parseInt(this.predictionPeriod))]
|
|
|
+
|
|
|
+ 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
|
|
|
+ },
|
|
|
+ label: function(context, data) {
|
|
|
+ const datasetLabel = data.datasets[context.datasetIndex].label
|
|
|
+ const value = context.value
|
|
|
+ return datasetLabel + ': ' + Number(value).toLocaleString()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ hover: {
|
|
|
+ mode: 'index',
|
|
|
+ intersect: false
|
|
|
+ },
|
|
|
+ scales: {
|
|
|
+ xAxes: [{
|
|
|
+ ticks: {
|
|
|
+ maxRotation: 0,
|
|
|
+ autoSkip: true,
|
|
|
+ maxTicksLimit: 12,
|
|
|
+ callback: function(value, index) {
|
|
|
+ return 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()
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ })
|
|
|
+ },
|
|
|
+ /** 计算趋势线 */
|
|
|
+ calculateTrendLine(data) {
|
|
|
+ if (data.length < 2) return data
|
|
|
+
|
|
|
+ const n = data.length
|
|
|
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0
|
|
|
+
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
+ sumX += i
|
|
|
+ sumY += data[i]
|
|
|
+ sumXY += i * data[i]
|
|
|
+ sumXX += i * i
|
|
|
+ }
|
|
|
+
|
|
|
+ const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
|
|
|
+ const intercept = (sumY - slope * sumX) / n
|
|
|
+
|
|
|
+ return data.map((_, i) => slope * i + intercept)
|
|
|
+ },
|
|
|
+ /** 生成预测日期 */
|
|
|
+ generatePredictionDates(period) {
|
|
|
+ const dates = []
|
|
|
+ const lastDate = new Date()
|
|
|
+
|
|
|
+ for (let i = 1; i <= period; i++) {
|
|
|
+ const date = new Date(lastDate)
|
|
|
+ date.setDate(date.getDate() + i)
|
|
|
+ dates.push(formatDate(date))
|
|
|
+ }
|
|
|
+
|
|
|
+ return dates
|
|
|
+ },
|
|
|
+ /** 获取趋势样式类 */
|
|
|
+ getTrendClass(trend) {
|
|
|
+ if (!trend) return ''
|
|
|
+ if (trend === '上升') return 'text-green-600 font-medium'
|
|
|
+ if (trend === '下降') return 'text-red-600 font-medium'
|
|
|
+ return 'text-gray-600 font-medium'
|
|
|
+ },
|
|
|
+ /** 导出预测结果 */
|
|
|
+ exportResults() {
|
|
|
+ if (!this.hasResults) {
|
|
|
+ this.$modal.msgError('暂无可导出的预测结果')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const payload = JSON.stringify(this.results || {}, null, 2)
|
|
|
+ const blob = new Blob([payload], { type: 'application/json;charset=utf-8' })
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = url
|
|
|
+ a.download = `sales_trend_prediction_${this.formatUploadDate(new Date())}.json`
|
|
|
+ document.body.appendChild(a)
|
|
|
+ a.click()
|
|
|
+ document.body.removeChild(a)
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ },
|
|
|
+ /** 格式化上传日期 */
|
|
|
+ formatUploadDate(date) {
|
|
|
+ const d = date instanceof Date ? date : new Date(date)
|
|
|
+ if (Number.isNaN(d.getTime())) return ''
|
|
|
+ const y = d.getFullYear()
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
+ const day = String(d.getDate()).padStart(2, '0')
|
|
|
+ return `${y}-${m}-${day}`
|
|
|
+ },
|
|
|
+ /** 选择第一个SKU */
|
|
|
+ pickFirstSku(obj) {
|
|
|
+ const keys = Object.keys(obj || {})
|
|
|
+ const first = keys.find(k => k !== '_analysis_summary_')
|
|
|
+ return first || ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.app-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 20px;
|
|
|
+
|
|
|
+ h2 {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ i {
|
|
|
+ margin-right: 8px;
|
|
|
+ color: #409EFF;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .page-desc {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+ margin: 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.mb-20 { margin-bottom: 20px; }
|
|
|
+
|
|
|
+.upload-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ background: #ffffff;
|
|
|
+ border: 1px solid #e6eaf2;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-upload ::v-deep .el-upload {
|
|
|
+ display: inline-flex;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-status {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #16a34a;
|
|
|
+ background: #f0fdf4;
|
|
|
+ border: 1px solid #dcfce7;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 6px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-status.muted {
|
|
|
+ color: #6b7280;
|
|
|
+ background: #f8fafc;
|
|
|
+ border-color: #e2e8f0;
|
|
|
+}
|
|
|
+
|
|
|
+.p-6 { padding: 24px; }
|
|
|
+.p-4 { padding: 16px; }
|
|
|
+.px-3 { padding-left: 12px; padding-right: 12px; }
|
|
|
+.px-4 { padding-left: 16px; padding-right: 16px; }
|
|
|
+.py-1 { padding-top: 4px; padding-bottom: 4px; }
|
|
|
+.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
|
|
+.py-3 { padding-top: 12px; padding-bottom: 12px; }
|
|
|
+.pt-2 { padding-top: 8px; }
|
|
|
+.mb-2 { margin-bottom: 8px; }
|
|
|
+.mb-4 { margin-bottom: 16px; }
|
|
|
+.mb-6 { margin-bottom: 24px; }
|
|
|
+.mb-8 { margin-bottom: 32px; }
|
|
|
+.mt-1 { margin-top: 4px; }
|
|
|
+.mt-4 { margin-top: 16px; }
|
|
|
+.mt-8 { margin-top: 32px; }
|
|
|
+.ml-2 { margin-left: 8px; }
|
|
|
+.mr-1 { margin-right: 4px; }
|
|
|
+
|
|
|
+.flex { display: flex; }
|
|
|
+.flex-wrap { flex-wrap: wrap; }
|
|
|
+.items-center { align-items: center; }
|
|
|
+.justify-between { justify-content: space-between; }
|
|
|
+.gap-2 { gap: 8px; }
|
|
|
+.gap-3 { gap: 12px; }
|
|
|
+.gap-4 { gap: 16px; }
|
|
|
+.gap-8 { gap: 20px; }
|
|
|
+
|
|
|
+.grid { display: grid; }
|
|
|
+.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
|
|
+.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
+.lg\:col-span-2 { grid-column: span 2 / span 2; }
|
|
|
+.overflow-x-auto { overflow-x: auto; }
|
|
|
+.min-w-full { min-width: 100%; }
|
|
|
+
|
|
|
+.text-3xl { font-size: 22px; line-height: 1.3; }
|
|
|
+.text-base { font-size: 16px; line-height: 1.5; }
|
|
|
+.text-lg { font-size: 18px; line-height: 1.5; }
|
|
|
+.text-sm { font-size: 14px; line-height: 1.5; }
|
|
|
+.text-xs { font-size: 12px; line-height: 1.4; }
|
|
|
+.font-bold { font-weight: 700; }
|
|
|
+.font-semibold { font-weight: 600; }
|
|
|
+.font-medium { font-weight: 500; }
|
|
|
+.text-left { text-align: left; }
|
|
|
+.uppercase { text-transform: uppercase; }
|
|
|
+.tracking-wide { letter-spacing: 0.04em; }
|
|
|
+.tracking-wider { letter-spacing: 0.06em; }
|
|
|
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+
|
|
|
+.text-gray-900 { color: #111827; }
|
|
|
+.text-gray-800 { color: #303133; }
|
|
|
+.text-gray-700 { color: #606266; }
|
|
|
+.text-gray-600 { color: #909399; }
|
|
|
+.text-gray-500 { color: #909399; }
|
|
|
+.text-gray-400 { color: #9ca3af; }
|
|
|
+.text-blue-600 { color: #2563eb; }
|
|
|
+.text-green-700 { color: #15803d; }
|
|
|
+.text-green-600 { color: #16a34a; }
|
|
|
+.text-red-700 { color: #b91c1c; }
|
|
|
+.text-red-600 { color: #dc2626; }
|
|
|
+.text-yellow-600 { color: #ca8a04; }
|
|
|
+
|
|
|
+.bg-white { background-color: #ffffff; }
|
|
|
+.bg-gray-50 { background-color: #f9fafb; }
|
|
|
+.bg-blue-500 { background-color: #3b82f6; }
|
|
|
+.bg-green-500 { background-color: #10b981; }
|
|
|
+.bg-purple-500 { background-color: #8b5cf6; }
|
|
|
+
|
|
|
+.border { border: 1px solid #e5e7eb; }
|
|
|
+.border-gray-200 { border-color: #e5e7eb; }
|
|
|
+.rounded-xl { border-radius: 0.75rem; }
|
|
|
+.rounded-lg { border-radius: 0.5rem; }
|
|
|
+.rounded-full { border-radius: 9999px; }
|
|
|
+
|
|
|
+.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
|
|
|
+
|
|
|
+.h-96 { height: 24rem; }
|
|
|
+
|
|
|
+.progress-bar {
|
|
|
+ height: 8px;
|
|
|
+ background-color: #e5e7eb;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .progress-value {
|
|
|
+ height: 100%;
|
|
|
+ transition: width 0.3s ease;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (min-width: 768px) {
|
|
|
+ .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
+}
|
|
|
+
|
|
|
+@media (min-width: 1024px) {
|
|
|
+ .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
|
+ .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
|
+ .lg\:col-span-2 { grid-column: span 2 / span 2; }
|
|
|
+}
|
|
|
+</style>
|