Browse Source

Merge remote-tracking branch 'origin/master'

Gogs 2 months ago
parent
commit
21f51c520d
3 changed files with 592 additions and 0 deletions
  1. 3 0
      .env.production
  2. 9 0
      src/api/client.js
  3. 580 0
      src/views/lifecycle/simulation/index.vue

+ 3 - 0
.env.production

@@ -6,3 +6,6 @@ ENV = 'production'
 
 # 若依管理系统/生产环境
 VUE_APP_BASE_API = '/prod-api'
+
+# python接口
+VUE_APP_PYTHON_API = '/py-api'

+ 9 - 0
src/api/client.js

@@ -96,6 +96,15 @@ export async function getInsights(payload) {
   return data
 }
 
+// SKU 生命周期策略模拟相关 API(显式命名,便于页面直接对接)
+export async function simulateSkuLifecycleStrategy(payload) {
+  return simulateStrategy(payload)
+}
+
+export async function getSkuLifecycleInsights(payload) {
+  return getInsights(payload)
+}
+
 // 爆款分析相关API
 export async function uploadHotProductFile(file) {
   const form = new FormData()

+ 580 - 0
src/views/lifecycle/simulation/index.vue

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