Spaces:
Sleeping
Sleeping
| <html lang="zh-CN" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>动态定价仿真实验室 | Dynamic Pricing Simulator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <style> | |
| body { background-color: #0f172a; color: #e2e8f0; font-family: 'Inter', sans-serif; } | |
| .glass-panel { | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(148, 163, 184, 0.1); | |
| border-radius: 0.75rem; | |
| } | |
| .neon-text { | |
| text-shadow: 0 0 10px rgba(56, 189, 248, 0.5); | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { width: 8px; } | |
| ::-webkit-scrollbar-track { background: #0f172a; } | |
| ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #475569; } | |
| </style> | |
| </head> | |
| <body class="min-h-screen p-6"> | |
| <div id="app" class="max-w-7xl mx-auto space-y-6"> | |
| <!-- Error Toast --> | |
| <div v-if="errorMsg" class="fixed top-4 right-4 bg-red-900/90 border border-red-500 text-red-200 px-4 py-3 rounded shadow-lg z-50 flex items-center gap-2"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| <span>${ errorMsg }</span> | |
| <button @click="errorMsg = ''" class="ml-2 hover:text-white">×</button> | |
| </div> | |
| <!-- Header --> | |
| <div class="flex justify-between items-center glass-panel p-6"> | |
| <div> | |
| <h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-cyan-300 neon-text"> | |
| 动态定价仿真实验室 | |
| </h1> | |
| <p class="text-slate-400 mt-1">SaaS/电商 实时定价策略模拟器</p> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <div class="text-right"> | |
| <div class="text-sm text-slate-400">总营收</div> | |
| <div class="text-2xl font-mono text-green-400">¥${ totalRevenue.toFixed(2) }</div> | |
| </div> | |
| <div class="text-right"> | |
| <div class="text-sm text-slate-400">运营天数</div> | |
| <div class="text-2xl font-mono text-blue-400">Day ${ day }</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Control & Visualization Grid --> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <!-- Left: Controls --> | |
| <div class="glass-panel p-6 space-y-8"> | |
| <!-- Data Management Section --> | |
| <div class="border-b border-slate-700 pb-6"> | |
| <h2 class="text-xl font-semibold mb-4 text-cyan-400">数据管理</h2> | |
| <div class="flex flex-col gap-3"> | |
| <div class="flex items-center gap-2"> | |
| <label class="flex-1 cursor-pointer bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm py-2 px-4 rounded transition text-center border border-slate-600"> | |
| <span>📤 导入历史数据 (JSON)</span> | |
| <input type="file" @change="handleFileUpload" class="hidden" accept=".json"> | |
| </label> | |
| <button @click="exportData" class="bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm py-2 px-3 rounded transition border border-slate-600" title="导出数据"> | |
| 📥 | |
| </button> | |
| </div> | |
| <p class="text-xs text-slate-500">支持导入 .json 格式的历史销售数据 (Max 5MB)</p> | |
| </div> | |
| </div> | |
| <div> | |
| <h2 class="text-xl font-semibold mb-4 text-cyan-400">定价策略控制</h2> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-slate-300 mb-2"> | |
| 我的定价 (¥${ price }) | |
| </label> | |
| <input type="range" v-model.number="price" min="10" max="200" step="1" | |
| class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"> | |
| <div class="flex justify-between text-xs text-slate-500 mt-1"> | |
| <span>¥10</span> | |
| <span>¥200</span> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-sm text-slate-400">竞争对手价格</span> | |
| <span class="text-sm font-mono text-orange-400">¥${ competitorPrice.toFixed(2) }</span> | |
| </div> | |
| <div class="w-full bg-slate-700 h-1.5 rounded-full overflow-hidden"> | |
| <div class="bg-orange-500 h-full transition-all duration-500" :style="{ width: (competitorPrice/200)*100 + '%' }"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <button @click="nextDay" :disabled="autoRunning" | |
| class="px-4 py-3 bg-slate-700 hover:bg-slate-600 rounded-lg font-medium transition flex justify-center items-center"> | |
| <span v-if="!autoRunning">📅 下一天</span> | |
| <span v-else>运行中...</span> | |
| </button> | |
| <button @click="toggleAuto" | |
| :class="autoRunning ? 'bg-red-500/20 text-red-400 border-red-500/50' : 'bg-cyan-500/20 text-cyan-400 border-cyan-500/50'" | |
| class="px-4 py-3 border rounded-lg font-medium transition flex justify-center items-center"> | |
| ${ autoRunning ? '⏸ 暂停模拟' : '▶ 自动运行' } | |
| </button> | |
| </div> | |
| <!-- AI Advisor Section --> | |
| <div class="pt-6 border-t border-slate-700"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-semibold text-purple-400">AI 智能分析</h3> | |
| <span class="text-xs px-2 py-1 bg-purple-900/50 rounded text-purple-300 border border-purple-700/50">Backend Powered</span> | |
| </div> | |
| <div v-if="aiAnalysis" class="space-y-3 text-sm"> | |
| <div class="flex justify-between"> | |
| <span class="text-slate-400">需求弹性系数:</span> | |
| <span :class="Math.abs(aiAnalysis.elasticity) > 1 ? 'text-green-400' : 'text-yellow-400'"> | |
| ${ aiAnalysis.elasticity } (${ Math.abs(aiAnalysis.elasticity) > 1 ? '富有弹性' : '缺乏弹性' }) | |
| </span> | |
| </div> | |
| <div class="flex justify-between items-center"> | |
| <span class="text-slate-400">建议最优价:</span> | |
| <span class="font-bold text-purple-400 text-lg">¥${ aiAnalysis.optimal_price }</span> | |
| </div> | |
| <button @click="applyOptimalPrice" class="w-full mt-2 py-2 bg-purple-600 hover:bg-purple-500 rounded text-white text-sm transition"> | |
| 应用智能定价 | |
| </button> | |
| </div> | |
| <div v-else class="text-center py-4 text-slate-500 text-sm"> | |
| 收集足够数据后 (Day > 5) <br>系统将自动生成分析报告 | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right: Visualization --> | |
| <div class="col-span-1 lg:col-span-2 space-y-6"> | |
| <!-- KPI Cards --> | |
| <div class="grid grid-cols-3 gap-4"> | |
| <div class="glass-panel p-4 text-center"> | |
| <div class="text-slate-400 text-xs uppercase tracking-wider">今日销量</div> | |
| <div class="text-2xl font-bold text-white mt-1">${ currentSales }</div> | |
| <div class="text-xs mt-1" :class="salesChange >= 0 ? 'text-green-400' : 'text-red-400'"> | |
| ${ salesChange >= 0 ? '↑' : '↓' } ${ Math.abs(salesChange) }% | |
| </div> | |
| </div> | |
| <div class="glass-panel p-4 text-center"> | |
| <div class="text-slate-400 text-xs uppercase tracking-wider">今日营收</div> | |
| <div class="text-2xl font-bold text-white mt-1">¥${ (currentSales * price).toFixed(0) }</div> | |
| </div> | |
| <div class="glass-panel p-4 text-center"> | |
| <div class="text-slate-400 text-xs uppercase tracking-wider">市场份额 (预估)</div> | |
| <div class="text-2xl font-bold text-white mt-1">${ marketShare }%</div> | |
| </div> | |
| </div> | |
| <!-- Main Chart --> | |
| <div class="glass-panel p-4 h-80"> | |
| <canvas id="mainChart"></canvas> | |
| </div> | |
| <!-- Secondary Chart: Demand Curve --> | |
| <div class="glass-panel p-4 h-64"> | |
| <h3 class="text-sm text-slate-400 mb-2">价格-销量 分布图 (Price Elasticity)</h3> | |
| <canvas id="scatterChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, watch } = Vue; | |
| createApp({ | |
| delimiters: ['${', '}'], // Avoid conflict with Jinja2 | |
| setup() { | |
| const history = ref([ | |
| { day: 1, price: 50, sales: 98, revenue: 4900, competitorPrice: 48 }, | |
| { day: 2, price: 50, sales: 102, revenue: 5100, competitorPrice: 49 }, | |
| { day: 3, price: 52, sales: 95, revenue: 4940, competitorPrice: 50 }, | |
| { day: 4, price: 52, sales: 93, revenue: 4836, competitorPrice: 49 }, | |
| { day: 5, price: 49, sales: 110, revenue: 5390, competitorPrice: 47 } | |
| ]); | |
| const day = ref(5); | |
| const price = ref(49); | |
| const competitorPrice = ref(47); | |
| const totalRevenue = ref(25166); | |
| const currentSales = ref(110); | |
| const autoRunning = ref(false); | |
| const aiAnalysis = ref(null); | |
| const errorMsg = ref(''); | |
| // Simulation Parameters | |
| const baseDemand = 100; | |
| const sensitivity = 2.5; // Price sensitivity | |
| const marketNoise = 0.1; // 10% randomness | |
| let chartInstance = null; | |
| let scatterInstance = null; | |
| let autoTimer = null; | |
| // KPI Calculation Helpers | |
| const salesChange = ref(0); | |
| const marketShare = ref(50); | |
| const nextDay = () => { | |
| day.value++; | |
| // 1. Competitor moves (Random Walk with mean reversion) | |
| const change = (Math.random() - 0.5) * 4; | |
| competitorPrice.value = Math.max(20, Math.min(100, competitorPrice.value + change)); | |
| // 2. Calculate Sales | |
| // Demand Function: Q = Base * (P_comp / P_mine)^sensitivity * Random | |
| const priceRatio = competitorPrice.value / price.value; | |
| let demand = baseDemand * Math.pow(priceRatio, sensitivity); | |
| // Add noise | |
| demand = demand * (1 + (Math.random() - 0.5) * marketNoise * 2); | |
| demand = Math.max(0, Math.round(demand)); | |
| const revenue = demand * price.value; | |
| // Update State | |
| if (day.value > 1) { | |
| const prevSales = history.value[history.value.length - 1].sales; | |
| salesChange.value = prevSales > 0 ? Math.round(((demand - prevSales) / prevSales) * 100) : 0; | |
| } | |
| currentSales.value = demand; | |
| totalRevenue.value += revenue; | |
| // Estimate Market Share (simple logic based on price ratio) | |
| // If prices equal, 50%. If I am cheaper, share > 50%. | |
| marketShare.value = Math.min(95, Math.max(5, Math.round(50 * Math.pow(priceRatio, sensitivity/2)))); | |
| // Record History | |
| history.value.push({ | |
| day: day.value, | |
| price: price.value, | |
| sales: demand, | |
| revenue: revenue, | |
| competitorPrice: competitorPrice.value | |
| }); | |
| updateCharts(); | |
| // Trigger AI Analysis every 5 days | |
| if (day.value % 5 === 0 && day.value > 0) { | |
| fetchAIAnalysis(); | |
| } | |
| }; | |
| const toggleAuto = () => { | |
| autoRunning.value = !autoRunning.value; | |
| if (autoRunning.value) { | |
| autoTimer = setInterval(nextDay, 800); // 0.8s per day | |
| } else { | |
| clearInterval(autoTimer); | |
| } | |
| }; | |
| const fetchAIAnalysis = async () => { | |
| try { | |
| const res = await fetch('/api/optimize', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| history: history.value, | |
| mc: 20 // Assume marginal cost of 20 for simplicity | |
| }) | |
| }); | |
| if (!res.ok) throw new Error(res.statusText); | |
| const data = await res.json(); | |
| if (data.status === 'success') { | |
| aiAnalysis.value = data; | |
| } | |
| } catch (e) { | |
| console.error("AI Analysis Failed:", e); | |
| // Don't show toast for background tasks to avoid annoyance | |
| } | |
| }; | |
| const applyOptimalPrice = () => { | |
| if (aiAnalysis.value) { | |
| price.value = aiAnalysis.value.optimal_price; | |
| } | |
| }; | |
| // Data Import/Export | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| // Frontend validation (double check) | |
| if (file.size > 5 * 1024 * 1024) { | |
| errorMsg.value = "文件大小不能超过 5MB"; | |
| return; | |
| } | |
| if (!file.name.toLowerCase().endsWith('.json')) { | |
| errorMsg.value = "只支持 JSON 文件"; | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.error || '上传失败'); | |
| } | |
| // Merge or replace history | |
| if (data.history && data.history.length > 0) { | |
| // Reset current state to match uploaded data end state | |
| const lastRecord = data.history[data.history.length - 1]; | |
| history.value = data.history; | |
| day.value = data.history.length; | |
| // Re-calculate totals | |
| totalRevenue.value = data.history.reduce((sum, item) => sum + (item.sales * item.price), 0); | |
| currentSales.value = lastRecord.sales; | |
| price.value = lastRecord.price; | |
| updateCharts(); | |
| alert(`成功导入 ${data.history.length} 条数据`); | |
| } | |
| } catch (e) { | |
| errorMsg.value = e.message; | |
| } | |
| // Reset input | |
| event.target.value = ''; | |
| }; | |
| const exportData = () => { | |
| const dataStr = JSON.stringify({ history: history.value }, null, 2); | |
| const blob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `pricing_history_day${day.value}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| // Chart.js Setup | |
| const initCharts = () => { | |
| const ctx = document.getElementById('mainChart').getContext('2d'); | |
| chartInstance = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [ | |
| { label: '营收 (Revenue)', data: [], borderColor: '#34d399', yAxisID: 'y' }, | |
| { label: '销量 (Sales)', data: [], borderColor: '#60a5fa', yAxisID: 'y1' }, | |
| { label: '我的价格', data: [], borderColor: '#94a3b8', borderDash: [5, 5], yAxisID: 'y1', hidden: true } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| scales: { | |
| y: { type: 'linear', display: true, position: 'left', grid: { color: '#334155' } }, | |
| y1: { type: 'linear', display: true, position: 'right', grid: { drawOnChartArea: false } }, | |
| x: { grid: { color: '#334155' } } | |
| }, | |
| plugins: { legend: { labels: { color: '#cbd5e1' } } } | |
| } | |
| }); | |
| const scatCtx = document.getElementById('scatterChart').getContext('2d'); | |
| scatterInstance = new Chart(scatCtx, { | |
| type: 'scatter', | |
| data: { | |
| datasets: [{ | |
| label: 'Price vs Sales', | |
| data: [], | |
| backgroundColor: 'rgba(244, 114, 182, 0.8)' | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { title: { display: true, text: 'Price', color: '#94a3b8' }, grid: { color: '#334155' } }, | |
| y: { title: { display: true, text: 'Sales', color: '#94a3b8' }, grid: { color: '#334155' } } | |
| }, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| }; | |
| const updateCharts = () => { | |
| // Update Line Chart | |
| const labels = history.value.map(h => `D${h.day || '?'}`); | |
| chartInstance.data.labels = labels; | |
| chartInstance.data.datasets[0].data = history.value.map(h => h.revenue || (h.sales * h.price)); | |
| chartInstance.data.datasets[1].data = history.value.map(h => h.sales); | |
| chartInstance.data.datasets[2].data = history.value.map(h => h.price); | |
| // Keep chart only showing last 30 days to avoid clutter | |
| if (labels.length > 30) { | |
| chartInstance.data.labels = labels.slice(-30); | |
| chartInstance.data.datasets.forEach(ds => ds.data = ds.data.slice(-30)); | |
| } | |
| chartInstance.update(); | |
| // Update Scatter Chart | |
| scatterInstance.data.datasets[0].data = history.value.map(h => ({ x: h.price, y: h.sales })); | |
| scatterInstance.update(); | |
| }; | |
| onMounted(() => { | |
| initCharts(); | |
| }); | |
| return { | |
| day, price, competitorPrice, totalRevenue, currentSales, | |
| autoRunning, aiAnalysis, salesChange, marketShare, errorMsg, | |
| nextDay, toggleAuto, applyOptimalPrice, handleFileUpload, exportData | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> |