|
|
@@ -0,0 +1,580 @@
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <div class="page-header">
|
|
|
+ <h2><i class="el-icon-data-analysis"></i>营销策略模拟与分析</h2>
|
|
|
+ <p class="page-desc">基于历史数据模拟不同营销策略对SKU生命周期的影响,并获取智能化策略建议</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <div class="flex flex-wrap items-center 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 value="">???</option>
|
|
|
+ <option v-for="k in skuOptions" :key="k" :value="k">{{ k }}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-button type="primary" :loading="simulationLoading" :disabled="!canRunSimulation" @click="runSimulation">
|
|
|
+ {{ simulationLoading ? '???...' : '??????' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="currentSkuData" 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">????</p>
|
|
|
+ <p class="text-lg font-medium text-gray-800">{{ currentSkuData.current_stage || '-' }}</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="currentSkuData.is_complete ? 'text-green-700' : 'text-orange-600'">
|
|
|
+ {{ currentSkuData.is_complete ? '??' : '???' }}
|
|
|
+ </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">{{ formatCurrency(currentSkuData.total_revenue) }}</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">{{ currentSkuData.total_quantity || '-' }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="currentSkuData" class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800 mb-6">????</h3>
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
+ <div>
|
|
|
+ <label class="text-sm font-medium text-gray-700">????</label>
|
|
|
+ <select
|
|
|
+ v-model="strategyConfig.type"
|
|
|
+ class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
+ >
|
|
|
+ <option value="promotion">????</option>
|
|
|
+ <option value="advertising">????</option>
|
|
|
+ <option value="price_cut">????</option>
|
|
|
+ <option value="price_increase">????</option>
|
|
|
+ </select>
|
|
|
+ <p class="text-xs text-gray-500 mt-2">{{ strategyDescriptions[strategyConfig.type] }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="!isPriceStrategy">
|
|
|
+ <label class="text-sm font-medium text-gray-700">????: {{ Math.round(strategyConfig.intensity * 100) }}%</label>
|
|
|
+ <input type="range" v-model.number="strategyConfig.intensity" min="0" max="1" step="0.1" class="range-input" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else>
|
|
|
+ <label class="text-sm font-medium text-gray-700">????: {{ strategyConfig.priceChange }}%</label>
|
|
|
+ <input
|
|
|
+ type="range"
|
|
|
+ v-model.number="strategyConfig.priceChange"
|
|
|
+ :min="strategyConfig.type === 'price_cut' ? -50 : 0"
|
|
|
+ :max="strategyConfig.type === 'price_cut' ? 0 : 50"
|
|
|
+ step="5"
|
|
|
+ class="range-input"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-sm font-medium text-gray-700">????</label>
|
|
|
+ <select
|
|
|
+ v-model="strategyConfig.startDate"
|
|
|
+ class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
+ >
|
|
|
+ <option v-for="d in availableDates" :key="d" :value="d">{{ formatDate(d) }}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label class="text-sm font-medium text-gray-700">????</label>
|
|
|
+ <select
|
|
|
+ v-model="strategyConfig.endDate"
|
|
|
+ class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
+ >
|
|
|
+ <option v-for="d in availableDates" :key="d" :value="d">{{ formatDate(d) }}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="simulationResult" class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800 mb-6">???????</h3>
|
|
|
+ <div class="h-96">
|
|
|
+ <canvas ref="comparisonChartRef"></canvas>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-blue-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">??????</p>
|
|
|
+ <p class="text-lg font-semibold text-gray-800">{{ formatCurrency(simulationResult.comparison.revenue_increase) }}</p>
|
|
|
+ <p class="text-sm text-gray-600">{{ formatSignedPercent(simulationResult.comparison.revenue_increase_pct) }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-green-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">?????</p>
|
|
|
+ <p class="text-lg font-semibold text-gray-800">{{ toFixedNum(simulationResult.comparison.quantity_increase, 0) }}</p>
|
|
|
+ <p class="text-sm text-gray-600">{{ formatSignedPercent(simulationResult.comparison.quantity_increase_pct) }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-yellow-50">
|
|
|
+ <p class="text-xs text-gray-500 uppercase tracking-wide">????????</p>
|
|
|
+ <p class="text-lg font-semibold text-gray-800">{{ formatCurrency(simulationResult.comparison.strategy_period_revenue_increase) }}</p>
|
|
|
+ <p class="text-sm text-gray-600">{{ formatSignedPercent(simulationResult.comparison.strategy_period_revenue_increase_pct) }}</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-sm text-gray-700">{{ formatDate(simulationResult.strategy_range.start_date) }} - {{ formatDate(simulationResult.strategy_range.end_date) }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="insights" class="bg-white rounded-xl p-6 mb-20 shadow-sm">
|
|
|
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">?????????</h3>
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">????</h4>
|
|
|
+ <p v-for="(item, idx) in insights.current_status" :key="'status-' + idx" class="text-sm text-gray-600 mb-1">- {{ item }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
|
|
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">????</h4>
|
|
|
+ <p v-for="(item, idx) in insights.recommendations" :key="'rec-' + idx" class="text-sm text-gray-600 mb-1">- {{ item }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { Chart } from 'chart.js'
|
|
|
+import { getResults, simulateSkuLifecycleStrategy, getSkuLifecycleInsights } from '@/api/client'
|
|
|
+import { formatCurrency, formatDate } from '../../../utils/format'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'LifecycleSimulation',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ simulationLoading: false,
|
|
|
+ insightsLoading: false,
|
|
|
+ comparisonChart: null,
|
|
|
+ simulationResult: null,
|
|
|
+ insights: null,
|
|
|
+ strategyConfig: {
|
|
|
+ type: 'promotion',
|
|
|
+ intensity: 0.5,
|
|
|
+ priceChange: -10,
|
|
|
+ startDate: '',
|
|
|
+ endDate: ''
|
|
|
+ },
|
|
|
+ strategyDescriptions: {
|
|
|
+ promotion: '?????????????????????????',
|
|
|
+ advertising: '???????????????????????',
|
|
|
+ price_cut: '???????????????????????',
|
|
|
+ price_increase: '?????????????????????'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ results() {
|
|
|
+ const state = this.$store && this.$store.state && this.$store.state.analysis
|
|
|
+ return (state && state.results) || {}
|
|
|
+ },
|
|
|
+ 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_')
|
|
|
+ },
|
|
|
+ currentSkuData() {
|
|
|
+ return (this.selectedSku && this.results[this.selectedSku]) || null
|
|
|
+ },
|
|
|
+ availableDates() {
|
|
|
+ const detail = this.currentSkuData || {}
|
|
|
+ return detail.date_series || []
|
|
|
+ },
|
|
|
+ isPriceStrategy() {
|
|
|
+ return this.strategyConfig.type === 'price_cut' || this.strategyConfig.type === 'price_increase'
|
|
|
+ },
|
|
|
+ canRunSimulation() {
|
|
|
+ return !!(this.currentSkuData && this.strategyConfig.startDate && this.strategyConfig.endDate)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ if (!Object.keys(this.results || {}).length) {
|
|
|
+ this.getList()
|
|
|
+ }
|
|
|
+ if (!this.selectedSku || !this.results[this.selectedSku]) {
|
|
|
+ const first = this.skuOptions[0] || ''
|
|
|
+ if (first) this.$store.dispatch('analysis/selectSku', first)
|
|
|
+ }
|
|
|
+ this.initializeForSku()
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ if (this.comparisonChart) this.comparisonChart.destroy()
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ selectedSku() {
|
|
|
+ this.initializeForSku()
|
|
|
+ },
|
|
|
+ results() {
|
|
|
+ if (!this.results || !this.results[this.selectedSku]) {
|
|
|
+ const first = this.skuOptions[0] || ''
|
|
|
+ if (first) this.$store.dispatch('analysis/selectSku', first)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ simulationResult() {
|
|
|
+ this.$nextTick(() => this.renderComparisonChart())
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ formatCurrency,
|
|
|
+ formatDate,
|
|
|
+ toFixedNum(n, digits) {
|
|
|
+ const v = Number(n)
|
|
|
+ if (Number.isNaN(v)) return '-'
|
|
|
+ return v.toFixed(digits)
|
|
|
+ },
|
|
|
+ formatSignedPercent(n) {
|
|
|
+ const v = Number(n)
|
|
|
+ if (Number.isNaN(v)) return '-'
|
|
|
+ return (v > 0 ? '+' : '') + v.toFixed(2) + '%'
|
|
|
+ },
|
|
|
+ async getList() {
|
|
|
+ try {
|
|
|
+ const response = await getResults()
|
|
|
+ const payload = response && response.data ? response.data : null
|
|
|
+ if (payload) {
|
|
|
+ this.$store.commit('analysis/SET_RESULTS', payload)
|
|
|
+ const firstSku = this.pickFirstSku(payload)
|
|
|
+ this.$store.commit('analysis/SET_SELECTED_SKU', firstSku)
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$store.commit('analysis/SET_RESULTS', {})
|
|
|
+ }
|
|
|
+ },
|
|
|
+ pickFirstSku(obj) {
|
|
|
+ const keys = Object.keys(obj || {})
|
|
|
+ const first = keys.find(k => k !== '_analysis_summary_')
|
|
|
+ return first || ''
|
|
|
+ },
|
|
|
+ initializeForSku() {
|
|
|
+ this.simulationResult = null
|
|
|
+ if (!this.currentSkuData) {
|
|
|
+ this.insights = null
|
|
|
+ this.strategyConfig.startDate = ''
|
|
|
+ this.strategyConfig.endDate = ''
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.setupDefaultDateRange()
|
|
|
+ this.loadInsights()
|
|
|
+ },
|
|
|
+ setupDefaultDateRange() {
|
|
|
+ const dates = this.availableDates || []
|
|
|
+ if (!dates.length) {
|
|
|
+ this.strategyConfig.startDate = ''
|
|
|
+ this.strategyConfig.endDate = ''
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!dates.includes(this.strategyConfig.startDate) || !dates.includes(this.strategyConfig.endDate)) {
|
|
|
+ const mid = Math.floor(dates.length / 2)
|
|
|
+ const startIdx = Math.max(0, mid - 15)
|
|
|
+ const endIdx = Math.min(dates.length - 1, mid + 15)
|
|
|
+ this.strategyConfig.startDate = dates[startIdx]
|
|
|
+ this.strategyConfig.endDate = dates[endIdx]
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async loadInsights() {
|
|
|
+ if (!this.currentSkuData) return
|
|
|
+ try {
|
|
|
+ this.insightsLoading = true
|
|
|
+ const response = await getSkuLifecycleInsights({
|
|
|
+ stage_statistics: this.currentSkuData.stage_statistics || {},
|
|
|
+ current_stage: this.currentSkuData.current_stage || '??',
|
|
|
+ is_complete: !!this.currentSkuData.is_complete
|
|
|
+ })
|
|
|
+ if (response && response.success) {
|
|
|
+ this.insights = response.data || null
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.insights = null
|
|
|
+ } finally {
|
|
|
+ this.insightsLoading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async runSimulation() {
|
|
|
+ if (!this.canRunSimulation) {
|
|
|
+ this.$modal.msgError('???? SKU ?????????')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const startIdx = this.availableDates.indexOf(this.strategyConfig.startDate)
|
|
|
+ const endIdx = this.availableDates.indexOf(this.strategyConfig.endDate)
|
|
|
+ if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
|
|
|
+ this.$modal.msgError('????/????????')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.simulationLoading = true
|
|
|
+ const payload = {
|
|
|
+ revenue_series: this.currentSkuData.revenue_series || this.currentSkuData.smoothed_revenue || [],
|
|
|
+ quantity_series: this.currentSkuData.quantity_series || this.currentSkuData.smoothed_quantity || [],
|
|
|
+ date_series: this.currentSkuData.date_series || [],
|
|
|
+ stages_map: this.currentSkuData.stages_map || [],
|
|
|
+ stage_statistics: this.currentSkuData.stage_statistics || {},
|
|
|
+ strategy_type: this.strategyConfig.type,
|
|
|
+ strategy_params: {
|
|
|
+ start_date: this.strategyConfig.startDate,
|
|
|
+ end_date: this.strategyConfig.endDate,
|
|
|
+ intensity: this.strategyConfig.intensity,
|
|
|
+ price_change: this.strategyConfig.priceChange
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await simulateSkuLifecycleStrategy(payload)
|
|
|
+ if (response && response.success) {
|
|
|
+ this.simulationResult = response.data || null
|
|
|
+ } else {
|
|
|
+ this.$modal.msgError((response && response.message) || '??????')
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ const msg = (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || '??????'
|
|
|
+ this.$modal.msgError(msg)
|
|
|
+ } finally {
|
|
|
+ this.simulationLoading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ renderComparisonChart() {
|
|
|
+ const canvas = this.$refs.comparisonChartRef
|
|
|
+ if (!canvas || !this.currentSkuData || !this.simulationResult) return
|
|
|
+
|
|
|
+ const labels = (this.currentSkuData.date_series || []).map(d => this.formatDate(d))
|
|
|
+ const originalRevenue = this.currentSkuData.revenue_series || this.currentSkuData.smoothed_revenue || []
|
|
|
+ const originalQuantity = this.currentSkuData.quantity_series || this.currentSkuData.smoothed_quantity || []
|
|
|
+ const simulatedRevenue = this.simulationResult.smoothed_simulated_revenue || []
|
|
|
+ const simulatedQuantity = this.simulationResult.smoothed_simulated_quantity || []
|
|
|
+ const strategyRange = this.simulationResult.strategy_range || { start_idx: 0, end_idx: 0 }
|
|
|
+
|
|
|
+ if (this.comparisonChart) this.comparisonChart.destroy()
|
|
|
+
|
|
|
+ this.comparisonChart = new Chart(canvas, {
|
|
|
+ type: 'line',
|
|
|
+ data: {
|
|
|
+ labels,
|
|
|
+ datasets: [
|
|
|
+ {
|
|
|
+ label: '?????',
|
|
|
+ data: originalRevenue,
|
|
|
+ borderColor: '#3b82f6',
|
|
|
+ backgroundColor: 'rgba(59,130,246,0.15)',
|
|
|
+ lineTension: 0.25,
|
|
|
+ pointRadius: 0,
|
|
|
+ yAxisID: 'y-axis-0'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '??????',
|
|
|
+ data: simulatedRevenue,
|
|
|
+ borderColor: '#10b981',
|
|
|
+ backgroundColor: 'rgba(16,185,129,0.15)',
|
|
|
+ lineTension: 0.25,
|
|
|
+ pointRadius: 0,
|
|
|
+ borderDash: [5, 5],
|
|
|
+ yAxisID: 'y-axis-0'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '????',
|
|
|
+ data: originalQuantity,
|
|
|
+ borderColor: '#64748b',
|
|
|
+ backgroundColor: 'rgba(100,116,139,0.15)',
|
|
|
+ lineTension: 0.25,
|
|
|
+ pointRadius: 0,
|
|
|
+ yAxisID: 'y-axis-1'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '?????',
|
|
|
+ data: simulatedQuantity,
|
|
|
+ borderColor: '#f97316',
|
|
|
+ backgroundColor: 'rgba(249,115,22,0.15)',
|
|
|
+ lineTension: 0.25,
|
|
|
+ pointRadius: 0,
|
|
|
+ borderDash: [5, 5],
|
|
|
+ yAxisID: 'y-axis-1'
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ options: {
|
|
|
+ responsive: true,
|
|
|
+ maintainAspectRatio: false,
|
|
|
+ tooltips: {
|
|
|
+ enabled: true,
|
|
|
+ mode: 'index',
|
|
|
+ intersect: false
|
|
|
+ },
|
|
|
+ hover: {
|
|
|
+ mode: 'index',
|
|
|
+ intersect: false
|
|
|
+ },
|
|
|
+ scales: {
|
|
|
+ xAxes: [{
|
|
|
+ ticks: {
|
|
|
+ maxRotation: 0,
|
|
|
+ autoSkip: true,
|
|
|
+ maxTicksLimit: 12
|
|
|
+ }
|
|
|
+ }],
|
|
|
+ yAxes: [
|
|
|
+ {
|
|
|
+ type: 'linear',
|
|
|
+ position: 'left',
|
|
|
+ id: 'y-axis-0',
|
|
|
+ scaleLabel: { display: true, labelString: '???' }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'linear',
|
|
|
+ position: 'right',
|
|
|
+ id: 'y-axis-1',
|
|
|
+ scaleLabel: { display: true, labelString: '??' },
|
|
|
+ gridLines: { drawOnChartArea: false }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ },
|
|
|
+ plugins: [{
|
|
|
+ id: 'strategy-range-marker',
|
|
|
+ afterDatasetsDraw(chart) {
|
|
|
+ const ctx = chart.ctx
|
|
|
+ const area = chart.chartArea
|
|
|
+ const xScale = chart.scales['x-axis-0']
|
|
|
+ if (!xScale || strategyRange.start_idx == null || strategyRange.end_idx == null) return
|
|
|
+
|
|
|
+ const x1 = xScale.getPixelForValue(strategyRange.start_idx)
|
|
|
+ const x2 = xScale.getPixelForValue(strategyRange.end_idx)
|
|
|
+
|
|
|
+ ctx.save()
|
|
|
+ ctx.fillStyle = 'rgba(245, 158, 11, 0.08)'
|
|
|
+ ctx.fillRect(x1, area.top, x2 - x1, area.bottom - area.top)
|
|
|
+
|
|
|
+ ctx.setLineDash([8, 4])
|
|
|
+ ctx.strokeStyle = '#f59e0b'
|
|
|
+ ctx.lineWidth = 2
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(x1, area.top)
|
|
|
+ ctx.lineTo(x1, area.bottom)
|
|
|
+ ctx.moveTo(x2, area.top)
|
|
|
+ ctx.lineTo(x2, area.bottom)
|
|
|
+ ctx.stroke()
|
|
|
+ ctx.setLineDash([])
|
|
|
+
|
|
|
+ ctx.fillStyle = '#92400e'
|
|
|
+ ctx.font = 'bold 12px sans-serif'
|
|
|
+ ctx.textAlign = 'center'
|
|
|
+ ctx.fillText('?????', (x1 + x2) / 2, area.top + 16)
|
|
|
+ ctx.restore()
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</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; }
|
|
|
+.p-6 { padding: 24px; }
|
|
|
+.p-4 { padding: 16px; }
|
|
|
+.px-4 { padding-left: 16px; padding-right: 16px; }
|
|
|
+.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
|
|
+
|
|
|
+.flex { display: flex; }
|
|
|
+.flex-wrap { flex-wrap: wrap; }
|
|
|
+.items-center { align-items: center; }
|
|
|
+.gap-3 { gap: 12px; }
|
|
|
+.gap-4 { gap: 16px; }
|
|
|
+
|
|
|
+.grid { display: grid; }
|
|
|
+.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
|
|
+
|
|
|
+.text-lg { font-size: 18px; }
|
|
|
+.text-sm { font-size: 14px; }
|
|
|
+.text-xs { font-size: 12px; }
|
|
|
+.font-semibold { font-weight: 600; }
|
|
|
+.font-medium { font-weight: 500; }
|
|
|
+.uppercase { text-transform: uppercase; }
|
|
|
+.tracking-wide { letter-spacing: 0.04em; }
|
|
|
+
|
|
|
+.text-gray-800 { color: #303133; }
|
|
|
+.text-gray-700 { color: #606266; }
|
|
|
+.text-gray-600 { color: #909399; }
|
|
|
+.text-gray-500 { color: #9ca3af; }
|
|
|
+.text-green-700 { color: #15803d; }
|
|
|
+.text-orange-600 { color: #ea580c; }
|
|
|
+
|
|
|
+.bg-white { background: #ffffff; }
|
|
|
+.bg-gray-50 { background: #f9fafb; }
|
|
|
+.bg-blue-50 { background: #eff6ff; }
|
|
|
+.bg-green-50 { background: #ecfdf5; }
|
|
|
+.bg-yellow-50 { background: #fffbeb; }
|
|
|
+
|
|
|
+.border { border-width: 1px; border-style: solid; }
|
|
|
+.border-gray-200 { border-color: #e5e7eb; }
|
|
|
+.border-gray-300 { border-color: #d1d5db; }
|
|
|
+.rounded-xl { border-radius: 8px; }
|
|
|
+.rounded-lg { border-radius: 10px; }
|
|
|
+.shadow-sm { border: 1px solid #e6eaf2; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); }
|
|
|
+
|
|
|
+.h-96 { height: 400px; }
|
|
|
+
|
|
|
+.sku-select {
|
|
|
+ min-width: 220px;
|
|
|
+ background: #ffffff;
|
|
|
+}
|
|
|
+
|
|
|
+.range-input {
|
|
|
+ width: 100%;
|
|
|
+ margin-top: 10px;
|
|
|
+ accent-color: #3b82f6;
|
|
|
+}
|
|
|
+
|
|
|
+@media (min-width: 768px) {
|
|
|
+ .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
+}
|
|
|
+
|
|
|
+@media (min-width: 1024px) {
|
|
|
+ .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
|
+}
|
|
|
+</style>
|