Trae Assistant
Fix: Restore missing refs and add default history data
6369f55
<!DOCTYPE html>
<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>