|
|
@@ -1,1114 +1,411 @@
|
|
|
<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">
|
|
|
+ <div class="sales-page">
|
|
|
+ <section class="header-bar">
|
|
|
+ <h1 class="header-title">趋势预测</h1>
|
|
|
+ <div class="header-actions">
|
|
|
<el-upload
|
|
|
- ref="toolbarUpload"
|
|
|
- class="toolbar-upload"
|
|
|
- :limit="1"
|
|
|
+ ref="salesUpload"
|
|
|
+ action=""
|
|
|
+ :auto-upload="false"
|
|
|
+ :show-file-list="false"
|
|
|
accept=".xlsx,.xls,.csv"
|
|
|
- :http-request="customUpload"
|
|
|
- :disabled="upload.isUploading"
|
|
|
- :on-change="handleFileChange"
|
|
|
- :on-success="handleUploadSuccess"
|
|
|
- :on-error="handleUploadError"
|
|
|
- :before-upload="beforeUpload"
|
|
|
+ :on-change="handleSalesFileChange"
|
|
|
+ >
|
|
|
+ <el-button size="small" plain icon="el-icon-upload2">销售数据</el-button>
|
|
|
+ </el-upload>
|
|
|
+ <el-upload
|
|
|
+ ref="productUpload"
|
|
|
+ action=""
|
|
|
:auto-upload="false"
|
|
|
:show-file-list="false"
|
|
|
+ accept=".xlsx,.xls"
|
|
|
+ :on-change="handleProductFileChange"
|
|
|
>
|
|
|
- <el-button plain>上传文件</el-button>
|
|
|
+ <el-button size="small" plain icon="el-icon-folder-add">产品资料</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>
|
|
|
-
|
|
|
- <!-- 预测设置 + 基础信息板块 -->
|
|
|
- <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">预测类型:</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">
|
|
|
- <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>
|
|
|
+ <el-button size="small" type="primary" :loading="loading" icon="el-icon-caret-right" @click="runPrediction">开始预测</el-button>
|
|
|
+ <el-button size="small" icon="el-icon-download" :disabled="!hasPrediction" @click="exportResults">导出</el-button>
|
|
|
</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">{{ 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>
|
|
|
- <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>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="filter-bar">
|
|
|
+ <div class="filter-group">
|
|
|
+ <label>预测类型</label>
|
|
|
+ <el-select v-model="predictType" size="small">
|
|
|
+ <el-option label="按 SKU" value="sku" />
|
|
|
+ <el-option label="按品类" value="category" />
|
|
|
+ </el-select>
|
|
|
</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 class="filter-group wide">
|
|
|
+ <label>{{ predictType === 'sku' ? 'SKU' : '品类' }}</label>
|
|
|
+ <el-select v-model="selectedItem" size="small" filterable placeholder="请选择">
|
|
|
+ <el-option v-for="item in selectOptions" :key="item" :label="item" :value="item" />
|
|
|
+ </el-select>
|
|
|
</div>
|
|
|
- <div class="h-96">
|
|
|
- <canvas ref="salesTrendRef"></canvas>
|
|
|
+ <div class="filter-group">
|
|
|
+ <label>预测周期</label>
|
|
|
+ <el-select v-model.number="predictionPeriod" size="small">
|
|
|
+ <el-option label="未来 7 天" :value="7" />
|
|
|
+ <el-option label="未来 15 天" :value="15" />
|
|
|
+ <el-option label="未来 30 天" :value="30" />
|
|
|
+ <el-option label="未来 60 天" :value="60" />
|
|
|
+ </el-select>
|
|
|
</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 class="file-hint">
|
|
|
+ <span v-if="salesFileName">销售数据:{{ salesFileName }}</span>
|
|
|
+ <span v-if="productFileName">产品资料:{{ productFileName }}</span>
|
|
|
+ <span v-if="!salesFileName && !productFileName">请先上传历史销售数据</span>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
|
|
|
- <!-- 预测指标与详情分组 -->
|
|
|
- <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>
|
|
|
+ <el-progress v-if="loading" :percentage="progress" :stroke-width="8" class="progress" />
|
|
|
+
|
|
|
+ <section class="chart-panel">
|
|
|
+ <div class="chart-header">
|
|
|
+ <div class="chart-summary">
|
|
|
+ <div>
|
|
|
+ <span class="summary-label">预测总量</span>
|
|
|
+ <strong>{{ formatNumber(predictedTotal) }}</strong>
|
|
|
</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>
|
|
|
+ <span class="summary-label">环比变化</span>
|
|
|
+ <strong :class="growthRate >= 0 ? 'up' : 'down'">{{ growthRateText }}</strong>
|
|
|
</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 class="legend">
|
|
|
+ <span><i class="solid blue"></i>历史销量</span>
|
|
|
+ <span><i class="dashed orange"></i>预测销量</span>
|
|
|
+ <span><i class="box gray"></i>置信区间</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <!-- 预测详情表格 -->
|
|
|
- <div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm">
|
|
|
- <h3 class="text-lg font-semibold text-gray-800 mb-6">预测详情</h3>
|
|
|
- <div class="overflow-x-auto">
|
|
|
- <table class="min-w-full divide-y divide-gray-200">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日期</th>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">历史销量</th>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">预测销量</th>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">偏差率</th>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">趋势</th>
|
|
|
- <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">置信度</th>
|
|
|
- </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>
|
|
|
- <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>
|
|
|
+ <div ref="trendChart" class="chart"></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="data-grid">
|
|
|
+ <div class="panel">
|
|
|
+ <div class="panel-head">
|
|
|
+ <h3>预测数值表</h3>
|
|
|
+ <el-input v-model="dateKeyword" size="small" prefix-icon="el-icon-search" placeholder="搜索日期" />
|
|
|
</div>
|
|
|
+ <el-table :data="filteredPredictionRows" size="small" height="330">
|
|
|
+ <el-table-column prop="date" label="日期" min-width="120" />
|
|
|
+ <el-table-column prop="quantity" label="预测销量" min-width="120">
|
|
|
+ <template slot-scope="{ row }">{{ formatNumber(row.quantity) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="lower" label="置信区间下限" min-width="130">
|
|
|
+ <template slot-scope="{ row }">{{ formatNumber(row.lower) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="upper" label="置信区间上限" min-width="130">
|
|
|
+ <template slot-scope="{ row }">{{ formatNumber(row.upper) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
</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 class="panel metric-panel">
|
|
|
+ <h3>关键指标</h3>
|
|
|
+ <div class="metric-card">
|
|
|
+ <span>预测期总销量</span>
|
|
|
+ <strong>{{ formatNumber(predictedTotal) }}</strong>
|
|
|
</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 class="metric-card orange">
|
|
|
+ <span>预测期日均销量</span>
|
|
|
+ <strong>{{ formatNumber(predictedAverage) }}</strong>
|
|
|
</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 class="metric-card green">
|
|
|
+ <span>预测置信度</span>
|
|
|
+ <strong>{{ effectiveConfidence }}%</strong>
|
|
|
+ <el-progress :percentage="effectiveConfidence" :show-text="false" color="#67c23a" />
|
|
|
</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 class="metric-card blue">
|
|
|
+ <span>高增长期</span>
|
|
|
+ <strong>{{ highGrowthPeriod }}</strong>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import { ref, computed, onMounted, beforeUnmount, watch } from 'vue'
|
|
|
-import { analyzeSaleTrendWithFile, getSaleTrendResults, predictSalesTrend } from '@/api/client'
|
|
|
-import { getToken } from '@/utils/auth'
|
|
|
-import { Chart } from 'chart.js'
|
|
|
-import { formatDate } from '../../../utils/format'
|
|
|
+import * as echarts from 'echarts'
|
|
|
+import {
|
|
|
+ analyzeSaleTrendWithFile,
|
|
|
+ getSaleTrendResults,
|
|
|
+ predictSalesTrend,
|
|
|
+ uploadProductFile,
|
|
|
+ getUploadProgress
|
|
|
+} from '@/api/client'
|
|
|
|
|
|
export default {
|
|
|
name: 'SalesTrendPrediction',
|
|
|
data() {
|
|
|
return {
|
|
|
- salesTrendChart: null,
|
|
|
- upload: {
|
|
|
- isUploading: false,
|
|
|
- fileName: '',
|
|
|
- pendingFileName: '',
|
|
|
- ignoreFileChange: false
|
|
|
- },
|
|
|
- predictionPeriod: '7',
|
|
|
+ chart: null,
|
|
|
+ loading: false,
|
|
|
+ progress: 0,
|
|
|
+ progressTimer: null,
|
|
|
+ salesFile: null,
|
|
|
+ productFile: null,
|
|
|
+ salesFileName: '',
|
|
|
+ productFileName: '',
|
|
|
+ productUploaded: false,
|
|
|
predictType: 'sku',
|
|
|
selectedItem: '',
|
|
|
- results: {}
|
|
|
+ predictionPeriod: 7,
|
|
|
+ analysisResults: {},
|
|
|
+ predictionResults: {},
|
|
|
+ dateKeyword: '',
|
|
|
+ confidence: 92
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
- hasResults() {
|
|
|
- return Object.keys(this.results || {}).length > 0
|
|
|
- },
|
|
|
selectOptions() {
|
|
|
+ return this.predictType === 'sku'
|
|
|
+ ? (this.analysisResults.sku_list || [])
|
|
|
+ : (this.analysisResults.category_list || [])
|
|
|
+ },
|
|
|
+ historicalDetail() {
|
|
|
+ if (!this.selectedItem) return {}
|
|
|
+ return this.predictType === 'sku'
|
|
|
+ ? ((this.analysisResults.data || {})[this.selectedItem] || {})
|
|
|
+ : ((this.analysisResults.categories || {})[this.selectedItem] || {})
|
|
|
+ },
|
|
|
+ predictionDetail() {
|
|
|
+ if (!this.selectedItem) return {}
|
|
|
if (this.predictType === 'sku') {
|
|
|
- return this.results.sku_list || []
|
|
|
- } else {
|
|
|
- return this.results.category_list || []
|
|
|
- }
|
|
|
- },
|
|
|
- detail() {
|
|
|
- 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
|
|
|
+ return ((this.predictionResults.sku_predictions || {})[this.selectedItem] || this.predictionResults.overall_prediction || {})
|
|
|
}
|
|
|
- },
|
|
|
- totalHistoricalSales() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.total_quantity || 0
|
|
|
- },
|
|
|
- averageDailySales() {
|
|
|
- const detail = this.detail || {}
|
|
|
- const totalQuantity = detail.total_quantity || 0
|
|
|
- const dateSeries = detail.date_series || []
|
|
|
- return dateSeries.length > 0 ? Math.round(totalQuantity / dateSeries.length) : 0
|
|
|
- },
|
|
|
- predictionAccuracy() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.prediction_accuracy || '85%'
|
|
|
- },
|
|
|
- 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 || {}
|
|
|
- const historicalSales = detail.historical_sales || []
|
|
|
- const predictedSales = detail.predicted_sales || []
|
|
|
- const dateSeries = detail.date_series || []
|
|
|
-
|
|
|
- // 生成预测详情
|
|
|
- const details = []
|
|
|
- for (let i = 0; i < predictedSales.length; i++) {
|
|
|
- const date = dateSeries[i] || `预测第${i+1}天`
|
|
|
- const historical = historicalSales[i] || 0
|
|
|
- const predicted = predictedSales[i] || 0
|
|
|
- const deviationRate = historical > 0 ? ((predicted - historical) / historical) * 100 : 0
|
|
|
-
|
|
|
- let trend = '稳定'
|
|
|
- if (i > 0 && predictedSales[i] > predictedSales[i-1]) {
|
|
|
- trend = '上升'
|
|
|
- } else if (i > 0 && predictedSales[i] < predictedSales[i-1]) {
|
|
|
- trend = '下降'
|
|
|
+ return ((this.predictionResults.category_predictions || {})[this.selectedItem] || this.predictionResults.overall_prediction || {})
|
|
|
+ },
|
|
|
+ hasPrediction() {
|
|
|
+ return (this.predictionDetail.quantity_series || []).length > 0
|
|
|
+ },
|
|
|
+ predictedTotal() {
|
|
|
+ return (this.predictionDetail.quantity_series || []).reduce((sum, value) => sum + Number(value || 0), 0)
|
|
|
+ },
|
|
|
+ predictedAverage() {
|
|
|
+ const series = this.predictionDetail.quantity_series || []
|
|
|
+ return series.length ? this.predictedTotal / series.length : 0
|
|
|
+ },
|
|
|
+ historicalAverage() {
|
|
|
+ const series = this.historicalDetail.quantity_series || []
|
|
|
+ return series.length ? series.reduce((sum, value) => sum + Number(value || 0), 0) / series.length : 0
|
|
|
+ },
|
|
|
+ growthRate() {
|
|
|
+ if (!this.historicalAverage) return 0
|
|
|
+ return ((this.predictedAverage - this.historicalAverage) / this.historicalAverage) * 100
|
|
|
+ },
|
|
|
+ growthRateText() {
|
|
|
+ const sign = this.growthRate >= 0 ? '+' : ''
|
|
|
+ return `${sign}${this.growthRate.toFixed(1)}%`
|
|
|
+ },
|
|
|
+ predictionRows() {
|
|
|
+ const dates = this.predictionDetail.date_series || []
|
|
|
+ const values = this.predictionDetail.quantity_series || []
|
|
|
+ return dates.map((date, index) => {
|
|
|
+ const quantity = Number(values[index] || 0)
|
|
|
+ const band = Math.max(quantity * this.confidenceBandRate, 1)
|
|
|
+ return {
|
|
|
+ date,
|
|
|
+ quantity,
|
|
|
+ lower: Math.max(0, quantity - band),
|
|
|
+ upper: quantity + band
|
|
|
}
|
|
|
-
|
|
|
- details.push({
|
|
|
- date: date,
|
|
|
- historicalSales: historical,
|
|
|
- predictedSales: predicted,
|
|
|
- deviationRate: Math.round(deviationRate * 100) / 100,
|
|
|
- trend: trend,
|
|
|
- confidence: 0.85 // 模拟值
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- return details
|
|
|
- },
|
|
|
- axyComponents() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.axymodel_components || null
|
|
|
- },
|
|
|
- mape() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.mape || '12%'
|
|
|
- },
|
|
|
- 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 || 150
|
|
|
- },
|
|
|
- mae() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.mae || 120
|
|
|
+ })
|
|
|
},
|
|
|
- rSquared() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.r_squared || 0.85
|
|
|
+ filteredPredictionRows() {
|
|
|
+ if (!this.dateKeyword) return this.predictionRows
|
|
|
+ return this.predictionRows.filter(row => row.date.includes(this.dateKeyword))
|
|
|
},
|
|
|
- modelAccuracy() {
|
|
|
- const detail = this.detail || {}
|
|
|
- return detail.model_accuracy || 85
|
|
|
+ highGrowthPeriod() {
|
|
|
+ if (!this.predictionRows.length) return '-'
|
|
|
+ const sorted = [...this.predictionRows].sort((a, b) => b.quantity - a.quantity)
|
|
|
+ const first = sorted[0]
|
|
|
+ return first ? first.date : '-'
|
|
|
},
|
|
|
- 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'
|
|
|
+ effectiveConfidence() {
|
|
|
+ const detailParams = this.predictionDetail.model_params || {}
|
|
|
+ const globalParams = this.predictionResults.control_params || {}
|
|
|
+ return Math.round(Number(detailParams.confidence || globalParams.confidence || this.confidence))
|
|
|
},
|
|
|
- modelAccuracyLevel() {
|
|
|
- const accuracy = parseFloat(this.modelAccuracy) || 0
|
|
|
- if (accuracy >= 80) return '(优秀)'
|
|
|
- if (accuracy >= 60) return '(良好)'
|
|
|
- return '(需改进)'
|
|
|
+ confidenceBandRate() {
|
|
|
+ return Math.max(0.04, Math.min(0.25, (100 - this.effectiveConfidence) / 100 + 0.04))
|
|
|
}
|
|
|
},
|
|
|
- mounted() {
|
|
|
- this.getList()
|
|
|
- },
|
|
|
- beforeUnmount() {
|
|
|
- if (this.salesTrendChart) this.salesTrendChart.destroy()
|
|
|
- },
|
|
|
watch: {
|
|
|
- detail() {
|
|
|
- this.renderSalesTrend()
|
|
|
+ selectedItem() {
|
|
|
+ this.$nextTick(this.renderChart)
|
|
|
},
|
|
|
- results() {
|
|
|
- if (!this.results || !this.selectedItem) {
|
|
|
- const first = this.selectOptions[0] || ''
|
|
|
- if (first) this.selectedItem = first
|
|
|
- }
|
|
|
+ predictType() {
|
|
|
+ this.selectedItem = this.selectOptions[0] || ''
|
|
|
+ if (this.selectedItem && this.analysisResults.summary) this.runPrediction(false)
|
|
|
},
|
|
|
predictionPeriod() {
|
|
|
- if (this.selectedItem) {
|
|
|
- this.predictSales()
|
|
|
- }
|
|
|
- },
|
|
|
- predictType() {
|
|
|
- if (this.results) {
|
|
|
- const first = this.selectOptions[0] || ''
|
|
|
- if (first) this.selectedItem = first
|
|
|
- if (this.selectedItem) {
|
|
|
- this.predictSales()
|
|
|
- }
|
|
|
- }
|
|
|
+ if (this.selectedItem && this.analysisResults.summary) this.runPrediction(false)
|
|
|
}
|
|
|
},
|
|
|
+ mounted() {
|
|
|
+ this.chart = echarts.init(this.$refs.trendChart)
|
|
|
+ window.addEventListener('resize', this.resizeChart)
|
|
|
+ this.loadCachedResults()
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ window.removeEventListener('resize', this.resizeChart)
|
|
|
+ this.stopProgress()
|
|
|
+ if (this.chart) this.chart.dispose()
|
|
|
+ },
|
|
|
methods: {
|
|
|
- /** 获取销售分析结果 */
|
|
|
- getList() {
|
|
|
- console.log('Getting sales trend results...')
|
|
|
- getSaleTrendResults().then(response => {
|
|
|
- console.log('Get results response:', response)
|
|
|
- if (response && response.success && response.data) {
|
|
|
- const results = response.data || {}
|
|
|
- console.log('Sales trend results:', results)
|
|
|
- this.results = results
|
|
|
- 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(error => {
|
|
|
- console.error('Error getting sales trend results:', error)
|
|
|
- this.results = {}
|
|
|
- })
|
|
|
+ handleSalesFileChange(file) {
|
|
|
+ this.salesFile = file.raw
|
|
|
+ this.salesFileName = file.name
|
|
|
},
|
|
|
- /** 刷新数据 */
|
|
|
- refreshData() {
|
|
|
- this.getList()
|
|
|
- this.$modal.msgSuccess('数据刷新成功')
|
|
|
+ handleProductFileChange(file) {
|
|
|
+ this.productFile = file.raw
|
|
|
+ this.productFileName = file.name
|
|
|
+ this.productUploaded = false
|
|
|
},
|
|
|
- /** 预测销量趋势 */
|
|
|
- predictSales() {
|
|
|
- if (!this.selectedItem) return
|
|
|
-
|
|
|
- const params = {
|
|
|
- predict_days: parseInt(this.predictionPeriod)
|
|
|
- }
|
|
|
-
|
|
|
- predictSalesTrend(params).then(response => {
|
|
|
- console.log('Predict response:', response)
|
|
|
- if (response && response.success && response.data) {
|
|
|
- const results = response.data || {}
|
|
|
- console.log('Prediction results:', results)
|
|
|
-
|
|
|
- // 更新results数据以匹配前端期望的结构
|
|
|
- if (this.predictType === 'sku') {
|
|
|
- if (!this.results.data) {
|
|
|
- this.results.data = {}
|
|
|
- }
|
|
|
-
|
|
|
- // 获取SKU的预测数据
|
|
|
- const skuPrediction = results.sku_predictions && results.sku_predictions[this.selectedItem]
|
|
|
- const overallPrediction = results.overall_prediction
|
|
|
-
|
|
|
- this.results.data[this.selectedItem] = {
|
|
|
- historical_sales: this.detail && this.detail.quantity_series ? this.detail.quantity_series : [],
|
|
|
- predicted_sales: skuPrediction ? skuPrediction.quantity_series : (overallPrediction ? overallPrediction.quantity_series : []),
|
|
|
- date_series: this.detail && this.detail.date_series ? this.detail.date_series : [],
|
|
|
- predicted_total_sales: skuPrediction ? skuPrediction.quantity_series.reduce((a, b) => a + b, 0) : (overallPrediction ? overallPrediction.quantity_series.reduce((a, b) => a + b, 0) : 0),
|
|
|
- predicted_average_daily_sales: skuPrediction ? skuPrediction.quantity_series.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : (overallPrediction ? overallPrediction.quantity_series.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : 0),
|
|
|
- predicted_max_sales: skuPrediction ? Math.max(...skuPrediction.quantity_series) : (overallPrediction ? Math.max(...overallPrediction.quantity_series) : 0),
|
|
|
- predicted_min_sales: skuPrediction ? Math.min(...skuPrediction.quantity_series) : (overallPrediction ? Math.min(...overallPrediction.quantity_series) : 0),
|
|
|
- predicted_max_sales_date: skuPrediction && skuPrediction.quantity_series.length > 0 ? skuPrediction.date_series[skuPrediction.quantity_series.indexOf(Math.max(...skuPrediction.quantity_series))] : (overallPrediction && overallPrediction.quantity_series.length > 0 ? overallPrediction.date_series[overallPrediction.quantity_series.indexOf(Math.max(...overallPrediction.quantity_series))] : null),
|
|
|
- predicted_min_sales_date: skuPrediction && skuPrediction.quantity_series.length > 0 ? skuPrediction.date_series[skuPrediction.quantity_series.indexOf(Math.min(...skuPrediction.quantity_series))] : (overallPrediction && overallPrediction.quantity_series.length > 0 ? overallPrediction.date_series[overallPrediction.quantity_series.indexOf(Math.min(...overallPrediction.quantity_series))] : null),
|
|
|
- prediction_accuracy: '85%', // 模拟值,实际应从模型评估中获取
|
|
|
- mape: '12%', // 模拟值
|
|
|
- rmse: 150, // 模拟值
|
|
|
- mae: 120, // 模拟值
|
|
|
- r_squared: 0.85, // 模拟值
|
|
|
- model_accuracy: 85, // 模拟值
|
|
|
- axymodel_components: skuPrediction ? {
|
|
|
- base_value: skuPrediction.model_params.a,
|
|
|
- trend_factor: skuPrediction.model_params.x,
|
|
|
- seasonal_factors: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7] // 模拟值
|
|
|
- } : (overallPrediction ? {
|
|
|
- base_value: overallPrediction.model_params.a,
|
|
|
- trend_factor: overallPrediction.model_params.x,
|
|
|
- seasonal_factors: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7] // 模拟值
|
|
|
- } : {})
|
|
|
- }
|
|
|
- } else {
|
|
|
- if (!this.results.categories) {
|
|
|
- this.results.categories = {}
|
|
|
- }
|
|
|
-
|
|
|
- // 获取品类的预测数据
|
|
|
- const categoryPrediction = results.category_predictions && results.category_predictions[this.selectedItem]
|
|
|
- const overallPrediction = results.overall_prediction
|
|
|
-
|
|
|
- this.results.categories[this.selectedItem] = {
|
|
|
- historical_sales: this.detail && this.detail.quantity_series ? this.detail.quantity_series : [],
|
|
|
- predicted_sales: categoryPrediction ? categoryPrediction.quantity_series : (overallPrediction ? overallPrediction.quantity_series : []),
|
|
|
- date_series: this.detail && this.detail.date_series ? this.detail.date_series : [],
|
|
|
- predicted_total_sales: categoryPrediction ? categoryPrediction.quantity_series.reduce((a, b) => a + b, 0) : (overallPrediction ? overallPrediction.quantity_series.reduce((a, b) => a + b, 0) : 0),
|
|
|
- predicted_average_daily_sales: categoryPrediction ? categoryPrediction.quantity_series.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : (overallPrediction ? overallPrediction.quantity_series.reduce((a, b) => a + b, 0) / parseInt(this.predictionPeriod) : 0),
|
|
|
- predicted_max_sales: categoryPrediction ? Math.max(...categoryPrediction.quantity_series) : (overallPrediction ? Math.max(...overallPrediction.quantity_series) : 0),
|
|
|
- predicted_min_sales: categoryPrediction ? Math.min(...categoryPrediction.quantity_series) : (overallPrediction ? Math.min(...overallPrediction.quantity_series) : 0),
|
|
|
- predicted_max_sales_date: categoryPrediction && categoryPrediction.quantity_series.length > 0 ? categoryPrediction.date_series[categoryPrediction.quantity_series.indexOf(Math.max(...categoryPrediction.quantity_series))] : (overallPrediction && overallPrediction.quantity_series.length > 0 ? overallPrediction.date_series[overallPrediction.quantity_series.indexOf(Math.max(...overallPrediction.quantity_series))] : null),
|
|
|
- predicted_min_sales_date: categoryPrediction && categoryPrediction.quantity_series.length > 0 ? categoryPrediction.date_series[categoryPrediction.quantity_series.indexOf(Math.min(...categoryPrediction.quantity_series))] : (overallPrediction && overallPrediction.quantity_series.length > 0 ? overallPrediction.date_series[overallPrediction.quantity_series.indexOf(Math.min(...overallPrediction.quantity_series))] : null),
|
|
|
- prediction_accuracy: '85%', // 模拟值
|
|
|
- mape: '12%', // 模拟值
|
|
|
- rmse: 150, // 模拟值
|
|
|
- mae: 120, // 模拟值
|
|
|
- r_squared: 0.85, // 模拟值
|
|
|
- model_accuracy: 85, // 模拟值
|
|
|
- axymodel_components: categoryPrediction ? {
|
|
|
- base_value: categoryPrediction.model_params.a,
|
|
|
- trend_factor: categoryPrediction.model_params.x,
|
|
|
- seasonal_factors: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7] // 模拟值
|
|
|
- } : (overallPrediction ? {
|
|
|
- base_value: overallPrediction.model_params.a,
|
|
|
- trend_factor: overallPrediction.model_params.x,
|
|
|
- seasonal_factors: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7] // 模拟值
|
|
|
- } : {})
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- this.$nextTick(() => {
|
|
|
- this.renderSalesTrend()
|
|
|
- })
|
|
|
- this.$modal.msgSuccess(`预测成功!预测了未来${this.predictionPeriod}天的销量趋势`)
|
|
|
- } else if (response && !response.success) {
|
|
|
- this.$modal.msgError(response.message || '预测失败,请重试')
|
|
|
- } else {
|
|
|
- this.$modal.msgError('预测失败,请重试')
|
|
|
- }
|
|
|
- }).catch(error => {
|
|
|
- console.error('预测失败:', error)
|
|
|
- this.$modal.msgError('预测失败,请检查服务是否正常运行')
|
|
|
- })
|
|
|
- },
|
|
|
- /** 文件选择改变处理 */
|
|
|
- handleFileChange(file, fileList) {
|
|
|
- if (this.upload.ignoreFileChange) return
|
|
|
- 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) {
|
|
|
- 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 isLt500M = file.size / 1024 / 1024 < 500
|
|
|
-
|
|
|
- if (!isExcel) {
|
|
|
- this.$modal.msgError('上传文件只能是 xlsx/xls/csv 格式!')
|
|
|
- return false
|
|
|
- }
|
|
|
- if (!isLt500M) {
|
|
|
- this.$modal.msgError('上传文件大小不能超过 500MB!')
|
|
|
- return false
|
|
|
- }
|
|
|
- return true
|
|
|
- },
|
|
|
- customUpload(options) {
|
|
|
- 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 and analysis...')
|
|
|
- this.upload.isUploading = true
|
|
|
- analyzeSaleTrendWithFile(file).then(response => {
|
|
|
- console.log('analyzeSaleTrendWithFile response:', response)
|
|
|
- this.upload.isUploading = false
|
|
|
+ async loadCachedResults() {
|
|
|
+ try {
|
|
|
+ const response = await getSaleTrendResults()
|
|
|
if (response && response.success) {
|
|
|
- const results = response.data || {}
|
|
|
- console.log('Upload analysis results:', results)
|
|
|
- this.results = results
|
|
|
- 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.message || '分析失败')
|
|
|
- options.onError(new Error(response.message || '分析失败'))
|
|
|
- }
|
|
|
- }).catch(error => {
|
|
|
- console.error('analyzeSaleTrendWithFile error:', error)
|
|
|
- this.upload.isUploading = false
|
|
|
- const msg = (error && error.message) || '文件上传失败,请重试'
|
|
|
- this.$modal.msgError(msg)
|
|
|
- options.onError(error)
|
|
|
- }).finally(() => {
|
|
|
- if (this.$refs.toolbarUpload) {
|
|
|
- this.upload.ignoreFileChange = true
|
|
|
- this.$refs.toolbarUpload.clearFiles()
|
|
|
- this.$nextTick(() => {
|
|
|
- this.upload.ignoreFileChange = false
|
|
|
- })
|
|
|
+ this.analysisResults = response.data || {}
|
|
|
+ this.selectedItem = this.selectOptions[0] || ''
|
|
|
+ if (this.selectedItem) await this.runPrediction(false)
|
|
|
}
|
|
|
- })
|
|
|
- },
|
|
|
- submitUpload() {
|
|
|
- console.log('submitUpload called')
|
|
|
- const target = this.$refs.toolbarUpload
|
|
|
- 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
|
|
|
+ } catch (e) {
|
|
|
+ this.analysisResults = {}
|
|
|
}
|
|
|
- console.log('Calling target.submit()')
|
|
|
- target.submit()
|
|
|
},
|
|
|
- /** 渲染销量趋势与预测图 */
|
|
|
- renderSalesTrend() {
|
|
|
- console.log('Rendering sales trend...')
|
|
|
- const canvas = this.$refs.salesTrendRef
|
|
|
- if (!canvas) {
|
|
|
- console.error('No canvas reference found')
|
|
|
+ async runPrediction(requireFile = true) {
|
|
|
+ if (requireFile && !this.salesFile && !this.analysisResults.summary) {
|
|
|
+ this.$modal.msgError('请先选择销售数据文件')
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- console.log('Detail data:', this.detail)
|
|
|
- const detail = this.detail || {}
|
|
|
- const historicalSales = detail.quantity_series || 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 predictionDates = this.generatePredictionDates(parseInt(this.predictionPeriod))
|
|
|
-
|
|
|
- // 合并历史和预测数据
|
|
|
- const combinedLabels = [...labels, ...predictionDates]
|
|
|
-
|
|
|
- console.log('Combined labels:', combinedLabels)
|
|
|
-
|
|
|
- if (this.salesTrendChart) this.salesTrendChart.destroy()
|
|
|
-
|
|
|
+ this.loading = true
|
|
|
+ this.startProgress()
|
|
|
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: '趋势线',
|
|
|
- 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: {
|
|
|
- x: {
|
|
|
- ticks: {
|
|
|
- maxRotation: 0,
|
|
|
- autoSkip: true,
|
|
|
- maxTicksLimit: 12
|
|
|
- }
|
|
|
- },
|
|
|
- y: {
|
|
|
- beginAtZero: true
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
- plugins: [{
|
|
|
- id: 'prediction-area',
|
|
|
- beforeDatasetsDraw(chart) {
|
|
|
- const ctx = chart.ctx
|
|
|
- const chartArea = chart.chartArea
|
|
|
- const xScale = chart.scales['x']
|
|
|
-
|
|
|
- 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')
|
|
|
+ if (this.productFile && !this.productUploaded) {
|
|
|
+ await uploadProductFile(this.productFile)
|
|
|
+ this.productUploaded = true
|
|
|
+ }
|
|
|
+ if (this.salesFile) {
|
|
|
+ const analyzed = await analyzeSaleTrendWithFile(this.salesFile)
|
|
|
+ if (!analyzed.success) throw new Error(analyzed.message || '销售数据分析失败')
|
|
|
+ this.analysisResults = analyzed.data || {}
|
|
|
+ this.selectedItem = this.selectOptions[0] || this.selectedItem
|
|
|
+ this.salesFile = null
|
|
|
+ }
|
|
|
+ const predicted = await predictSalesTrend({ predict_days: this.predictionPeriod })
|
|
|
+ if (!predicted.success) throw new Error(predicted.message || '预测失败')
|
|
|
+ this.predictionResults = predicted.data || {}
|
|
|
+ this.$nextTick(this.renderChart)
|
|
|
+ this.$modal.msgSuccess('预测完成')
|
|
|
} catch (error) {
|
|
|
- console.error('Error rendering chart:', error)
|
|
|
- }
|
|
|
- },
|
|
|
- /** 计算趋势线 */
|
|
|
- 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
|
|
|
+ this.$modal.msgError(error.message || '预测失败,请检查服务状态')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ this.progress = 100
|
|
|
+ this.stopProgress()
|
|
|
}
|
|
|
-
|
|
|
- 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'
|
|
|
+ startProgress() {
|
|
|
+ this.progress = 5
|
|
|
+ this.stopProgress()
|
|
|
+ this.progressTimer = setInterval(async () => {
|
|
|
+ try {
|
|
|
+ const res = await getUploadProgress()
|
|
|
+ if (res && res.success) this.progress = Math.min(99, res.progress || this.progress)
|
|
|
+ } catch (e) {
|
|
|
+ this.progress = Math.min(95, this.progress + 8)
|
|
|
+ }
|
|
|
+ }, 600)
|
|
|
+ },
|
|
|
+ stopProgress() {
|
|
|
+ if (this.progressTimer) clearInterval(this.progressTimer)
|
|
|
+ this.progressTimer = null
|
|
|
+ },
|
|
|
+ renderChart() {
|
|
|
+ if (!this.chart) return
|
|
|
+ const histDates = this.historicalDetail.date_series || []
|
|
|
+ const histValues = this.historicalDetail.quantity_series || []
|
|
|
+ const predDates = this.predictionDetail.date_series || []
|
|
|
+ const predValues = this.predictionDetail.quantity_series || []
|
|
|
+ const labels = [...histDates, ...predDates]
|
|
|
+ const lower = predValues.map(v => Math.max(0, Number(v || 0) * (1 - this.confidenceBandRate)))
|
|
|
+ const upper = predValues.map(v => Number(v || 0) * (1 + this.confidenceBandRate))
|
|
|
+ this.chart.setOption({
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ grid: { top: 30, left: 45, right: 24, bottom: 45 },
|
|
|
+ xAxis: { type: 'category', data: labels, boundaryGap: false },
|
|
|
+ yAxis: { type: 'value' },
|
|
|
+ series: [
|
|
|
+ { name: '历史销量', type: 'line', smooth: true, data: [...histValues, ...predDates.map(() => null)], itemStyle: { color: '#3b82f6' } },
|
|
|
+ { name: '预测销量', type: 'line', smooth: true, lineStyle: { type: 'dashed' }, data: [...histDates.map(() => null), ...predValues], itemStyle: { color: '#f59e0b' } },
|
|
|
+ { name: '置信下限', type: 'line', smooth: true, symbol: 'none', lineStyle: { opacity: 0 }, data: [...histDates.map(() => null), ...lower], stack: 'confidence' },
|
|
|
+ { name: '置信区间', type: 'line', smooth: true, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(107,114,128,.16)' }, data: [...histDates.map(() => null), ...upper.map((v, i) => v - lower[i])], stack: 'confidence' }
|
|
|
+ ]
|
|
|
+ })
|
|
|
},
|
|
|
- /** 获取置信度样式类 */
|
|
|
- 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'
|
|
|
+ resizeChart() {
|
|
|
+ if (this.chart) this.chart.resize()
|
|
|
},
|
|
|
- /** 导出预测结果 */
|
|
|
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 blob = new Blob([JSON.stringify({ analysis: this.analysisResults, prediction: this.predictionResults }, null, 2)], { 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)
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = url
|
|
|
+ link.download = `sales_prediction_${Date.now()}.json`
|
|
|
+ link.click()
|
|
|
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 || ''
|
|
|
+ formatNumber(value) {
|
|
|
+ return Math.round(Number(value || 0)).toLocaleString()
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</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>
|
|
|
+.sales-page { padding: 20px; max-width: 1400px; margin: 0 auto; background: #f0f2f5; min-height: calc(100vh - 84px); color: #303133; }
|
|
|
+.header-bar, .filter-bar, .chart-panel, .panel { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
|
|
+.header-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; margin-bottom: 20px; gap: 16px; flex-wrap: wrap; }
|
|
|
+.header-title { margin: 0; font-size: 20px; font-weight: 600; }
|
|
|
+.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
|
+.filter-bar { display: flex; align-items: flex-end; gap: 15px; padding: 20px 24px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
|
+.filter-group { display: flex; flex-direction: column; gap: 6px; min-width: 150px; }
|
|
|
+.filter-group.wide { min-width: 240px; }
|
|
|
+.filter-group label { font-size: 12px; color: #606266; font-weight: 500; }
|
|
|
+.file-hint { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; color: #67c23a; font-size: 13px; }
|
|
|
+.progress { margin-bottom: 16px; }
|
|
|
+.chart-panel { padding: 24px; margin-bottom: 20px; }
|
|
|
+.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 16px; flex-wrap: wrap; }
|
|
|
+.chart-summary { display: flex; gap: 28px; }
|
|
|
+.chart-summary div { display: flex; flex-direction: column; gap: 4px; }
|
|
|
+.summary-label { color: #909399; font-size: 12px; }
|
|
|
+.chart-summary strong { font-size: 24px; }
|
|
|
+.up { color: #67c23a; }
|
|
|
+.down { color: #f56c6c; }
|
|
|
+.legend { display: flex; gap: 15px; color: #606266; font-size: 12px; flex-wrap: wrap; }
|
|
|
+.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
|
+.legend i { display: inline-block; }
|
|
|
+.solid { width: 20px; height: 2px; }
|
|
|
+.dashed { width: 20px; border-top: 2px dashed; }
|
|
|
+.box { width: 12px; height: 12px; background: rgba(107,114,128,.2); border: 1px solid #9ca3af; }
|
|
|
+.blue { background: #3b82f6; color: #3b82f6; }
|
|
|
+.orange { color: #f59e0b; }
|
|
|
+.gray { color: #9ca3af; }
|
|
|
+.chart { height: 400px; }
|
|
|
+.data-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
|
|
|
+.panel { padding: 24px; min-width: 0; }
|
|
|
+.panel h3 { margin: 0 0 18px; font-size: 16px; }
|
|
|
+.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
|
|
+.panel-head h3 { margin: 0; }
|
|
|
+.panel-head .el-input { max-width: 220px; }
|
|
|
+.metric-panel { display: flex; flex-direction: column; gap: 15px; }
|
|
|
+.metric-card { border-left: 4px solid #667eea; background: linear-gradient(135deg,#f8f9fa,#eef1f5); border-radius: 8px; padding: 18px; }
|
|
|
+.metric-card span { display: block; color: #909399; font-size: 12px; margin-bottom: 8px; }
|
|
|
+.metric-card strong { font-size: 24px; }
|
|
|
+.metric-card.orange { border-left-color: #f59e0b; }
|
|
|
+.metric-card.green { border-left-color: #67c23a; }
|
|
|
+.metric-card.blue { border-left-color: #3b82f6; }
|
|
|
+@media (max-width: 1100px) { .data-grid { grid-template-columns: 1fr; } .file-hint { margin-left: 0; } }
|
|
|
+</style>
|