|
|
@@ -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) {
|