Trae Assistant
Initial commit: Enhanced Queue Strategy Lab with Import/Export and Error Handling
6c4d394
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能排队策略实验室 | Queue Strategy Lab</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/echarts@5.4.3/dist/echarts.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* Glassmorphism */
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
body {
background-color: #0f172a; /* Slate 900 */
color: #e2e8f0;
}
.input-group label {
display: block;
font-size: 0.875rem;
color: #94a3b8;
margin-bottom: 0.25rem;
}
.input-group input {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
color: white;
padding: 0.5rem;
border-radius: 0.375rem;
}
.btn-primary {
background: #4f46e5;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary:hover {
background: #4338ca;
}
.btn-secondary {
background: #334155;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
}
.btn-secondary:hover {
background: #475569;
}
canvas {
width: 100%;
height: 300px;
background: #1e293b;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div id="app" class="min-h-screen flex flex-col">
<!-- Header -->
<header class="glass p-4 border-b border-gray-700 flex justify-between items-center z-10">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center text-xl">🚦</div>
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-cyan-400">
智能排队策略实验室
</h1>
</div>
<div class="flex gap-2">
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".json">
<button @click="triggerUpload" class="btn-secondary text-sm" title="导入配置">
<i class="fas fa-file-upload mr-1"></i> 导入
</button>
<button @click="exportConfig" class="btn-secondary text-sm" title="导出配置">
<i class="fas fa-file-download mr-1"></i> 导出
</button>
<button @click="showSaveModal = true" class="btn-secondary text-sm">
<i class="fas fa-save mr-1"></i> 保存
</button>
<button @click="showLoadModal = true" class="btn-secondary text-sm">
<i class="fas fa-folder-open mr-1"></i> 加载
</button>
</div>
</header>
<main class="flex-1 flex overflow-hidden">
<!-- Sidebar Controls -->
<aside class="w-80 glass border-r border-gray-700 flex flex-col overflow-y-auto p-4 gap-6">
<!-- Section: Simulation Params -->
<div>
<h3 class="text-lg font-semibold text-indigo-400 mb-3"><i class="fas fa-sliders-h mr-2"></i>仿真参数</h3>
<div class="space-y-3">
<div class="input-group">
<label>客户到达率 (人/小时)</label>
<input type="number" v-model.number="params.arrival_rate" min="1" max="1000">
</div>
<div class="input-group">
<label>平均服务时间 (分钟)</label>
<input type="number" v-model.number="params.service_time" min="0.1" step="0.1">
</div>
<div class="input-group">
<label>服务时间波动 (标准差)</label>
<input type="number" v-model.number="params.service_std" min="0" step="0.1">
</div>
<div class="input-group">
<label>服务窗口数量</label>
<input type="number" v-model.number="params.num_servers" min="1" max="50">
</div>
<div class="input-group">
<label>仿真时长 (分钟)</label>
<input type="number" v-model.number="params.duration" min="10" max="1440">
</div>
</div>
<button @click="runSimulation" class="btn-primary w-full mt-4">
<i class="fas fa-play mr-2"></i> 开始仿真演示
</button>
</div>
<!-- Section: Cost Params -->
<div class="border-t border-gray-700 pt-4">
<h3 class="text-lg font-semibold text-green-400 mb-3"><i class="fas fa-chart-line mr-2"></i>成本优化</h3>
<div class="space-y-3">
<div class="input-group">
<label>服务员时薪 ($/小时)</label>
<input type="number" v-model.number="params.cost_server" min="1">
</div>
<div class="input-group">
<label>客户等待成本 ($/小时)</label>
<input type="number" v-model.number="params.cost_wait" min="1">
<p class="text-xs text-gray-500 mt-1">客户因等待产生的隐性损失或不满折现</p>
</div>
</div>
<button @click="runOptimization" class="btn-primary w-full mt-4 bg-green-600 hover:bg-green-700">
<i class="fas fa-calculator mr-2"></i> 运行优化分析
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-y-auto bg-slate-900 p-6 gap-6">
<!-- Top: Animation & Live Stats -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[350px]">
<!-- Visualizer -->
<div class="lg:col-span-2 glass rounded-xl p-4 flex flex-col relative">
<div class="flex justify-between items-center mb-2">
<h3 class="font-semibold text-gray-300">实时队列视图</h3>
<div class="text-sm text-indigo-400">
时间: <span class="font-mono">${ currentTime.toFixed(1) }</span> min
</div>
</div>
<canvas ref="simCanvas" class="flex-1 w-full rounded bg-slate-800 border border-slate-700"></canvas>
<!-- Legend -->
<div class="absolute bottom-6 left-6 flex gap-4 text-xs text-gray-400">
<div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-blue-500"></div> 排队中</div>
<div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-green-500"></div> 服务中</div>
<div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-gray-500"></div> 空闲窗口</div>
</div>
</div>
<!-- Live Stats -->
<div class="glass rounded-xl p-4 flex flex-col justify-center gap-4">
<h3 class="font-semibold text-gray-300 border-b border-gray-700 pb-2">仿真统计结果</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-800 p-3 rounded-lg text-center">
<div class="text-xs text-gray-500">平均等待</div>
<div class="text-2xl font-bold text-indigo-400">${ metrics.avg_wait } <span class="text-xs">min</span></div>
</div>
<div class="bg-slate-800 p-3 rounded-lg text-center">
<div class="text-xs text-gray-500">最大等待</div>
<div class="text-2xl font-bold text-red-400">${ metrics.max_wait } <span class="text-xs">min</span></div>
</div>
<div class="bg-slate-800 p-3 rounded-lg text-center">
<div class="text-xs text-gray-500">已服务人数</div>
<div class="text-2xl font-bold text-green-400">${ metrics.served }</div>
</div>
<div class="bg-slate-800 p-3 rounded-lg text-center">
<div class="text-xs text-gray-500">窗口利用率</div>
<div class="text-2xl font-bold text-yellow-400">${ metrics.utilization }<span class="text-sm">%</span></div>
</div>
</div>
<div v-if="loading" class="text-center text-indigo-400 animate-pulse">
<i class="fas fa-circle-notch fa-spin mr-2"></i> 计算中...
</div>
</div>
</div>
<!-- Bottom: Charts -->
<div class="glass rounded-xl p-4 flex-1 min-h-[400px]">
<h3 class="font-semibold text-gray-300 mb-4">成本优化分析 (服务窗口数 vs 总成本)</h3>
<div ref="chartContainer" class="w-full h-[350px]"></div>
</div>
</div>
</main>
<!-- Save Modal -->
<div v-if="showSaveModal" class="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50">
<div class="glass p-6 rounded-xl w-96">
<h3 class="text-xl font-bold mb-4">保存配置</h3>
<input v-model="saveName" placeholder="输入配置名称 (如: 早高峰)" class="w-full bg-slate-800 p-2 rounded mb-4 text-white">
<div class="flex justify-end gap-2">
<button @click="showSaveModal = false" class="btn-secondary">取消</button>
<button @click="saveConfig" class="btn-primary">保存</button>
</div>
</div>
</div>
<!-- Load Modal -->
<div v-if="showLoadModal" class="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50">
<div class="glass p-6 rounded-xl w-96">
<h3 class="text-xl font-bold mb-4">加载配置</h3>
<ul class="space-y-2 max-h-60 overflow-y-auto mb-4">
<li v-for="(cfg, name) in savedConfigs" :key="name" class="flex justify-between items-center bg-slate-800 p-2 rounded hover:bg-slate-700 cursor-pointer" @click="loadConfig(name)">
<span>${ name }</span>
<button @click.stop="deleteConfig(name)" class="text-red-400 hover:text-red-300"><i class="fas fa-trash"></i></button>
</li>
<li v-if="Object.keys(savedConfigs).length === 0" class="text-gray-500 text-center">暂无保存的配置</li>
</ul>
<div class="flex justify-end gap-2">
<button @click="showLoadModal = false" class="btn-secondary">关闭</button>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, reactive, watch, nextTick } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
// State
const params = reactive({
arrival_rate: 120,
service_time: 2.0,
service_std: 0.5,
num_servers: 5,
duration: 60,
cost_server: 20,
cost_wait: 60
});
const metrics = reactive({ avg_wait: 0, max_wait: 0, served: 0, utilization: 0 });
const loading = ref(false);
const currentTime = ref(0);
// Modals
const showSaveModal = ref(false);
const showLoadModal = ref(false);
const saveName = ref('');
const savedConfigs = ref({});
const fileInput = ref(null);
// Canvas & Chart Refs
const simCanvas = ref(null);
const chartContainer = ref(null);
let chartInstance = null;
let animationFrame = null;
// Simulation State
let traceData = [];
let playbackSpeed = 1; // Multiplier
let customers = []; // { id, state: 'queue'|'service', serverIdx, x, y, targetX, targetY }
let servers = []; // { id, busy: false, customerId: null }
// --- Methods ---
const runSimulation = async () => {
loading.value = true;
try {
const res = await fetch('/api/simulate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
const data = await res.json();
// Update metrics
Object.assign(metrics, data.metrics);
// Start Visualization
traceData = data.trace;
startAnimation();
} catch (e) {
console.error(e);
alert('仿真请求失败');
} finally {
loading.value = false;
}
};
const runOptimization = async () => {
loading.value = true;
try {
const res = await fetch('/api/optimize', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
const data = await res.json();
renderChart(data.results);
} catch (e) {
console.error(e);
alert('优化请求失败');
} finally {
loading.value = false;
}
};
// --- Visualization Logic ---
const startAnimation = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
// Reset
currentTime.value = 0;
customers = [];
servers = Array.from({length: params.num_servers}, (_, i) => ({ id: i, busy: false, customerId: null }));
// Canvas Setup
const canvas = simCanvas.value;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
let lastFrameTime = performance.now();
let traceIndex = 0;
const animate = (now) => {
const dt = (now - lastFrameTime) / 1000; // seconds
lastFrameTime = now;
// Advance simulation time (1 real sec = 1 sim min * speed)
// Let's make it fast: 1 real sec = 10 sim mins
const simSpeed = 5.0;
currentTime.value += dt * simSpeed;
// Process events up to currentTime
while (traceIndex < traceData.length && traceData[traceIndex].time <= currentTime.value) {
const event = traceData[traceIndex];
processEvent(event);
traceIndex++;
}
// Draw
drawScene(ctx, width, height);
if (currentTime.value < params.duration || customers.length > 0) {
animationFrame = requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
};
const processEvent = (event) => {
if (event.type === 'arrival') {
customers.push({
id: event.id,
state: 'queue',
x: 50, // Start left
y: 150,
color: '#3b82f6' // Blue
});
} else if (event.type === 'start') {
// Find customer
const cust = customers.find(c => c.id === event.id);
if (cust) {
cust.state = 'service';
cust.color = '#22c55e'; // Green
// Find free server
const server = servers.find(s => !s.busy);
if (server) {
server.busy = true;
server.customerId = cust.id;
cust.serverIdx = server.id;
}
}
} else if (event.type === 'finish') {
const cust = customers.find(c => c.id === event.id);
if (cust) {
// Mark for removal or move to exit
cust.state = 'exit';
// Free server
if (cust.serverIdx !== undefined) {
const server = servers[cust.serverIdx];
if (server) {
server.busy = false;
server.customerId = null;
}
}
}
}
};
const drawScene = (ctx, w, h) => {
ctx.clearRect(0, 0, w, h);
// Draw Server Booths (Right side)
const serverX = w - 100;
const serverGap = h / (params.num_servers + 1);
servers.forEach((s, i) => {
const y = (i + 1) * serverGap;
ctx.fillStyle = s.busy ? '#1e293b' : '#334155'; // Darker if busy (occupied)
ctx.strokeStyle = s.busy ? '#22c55e' : '#64748b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(serverX, y - 15, 60, 30, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#94a3b8';
ctx.font = '12px sans-serif';
ctx.fillText(`窗口 ${i+1}`, serverX + 10, y + 5);
});
// Draw Queue Area (Left)
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
ctx.fillRect(20, 20, w - 150, h - 40);
// Draw Customers
// Queue positions logic: organize them in lines
let queueIdx = 0;
// Remove exited customers smoothly
customers = customers.filter(c => !(c.state === 'exit' && c.x > w));
customers.forEach(c => {
let targetX, targetY;
if (c.state === 'queue') {
// Simple wrapping queue
const col = Math.floor(queueIdx / 10);
const row = queueIdx % 10;
targetX = 50 + col * 20;
targetY = 50 + row * 20;
queueIdx++;
} else if (c.state === 'service') {
// Move to assigned server
const sY = (c.serverIdx + 1) * serverGap;
targetX = serverX + 30;
targetY = sY;
} else if (c.state === 'exit') {
targetX = w + 50;
targetY = c.y; // Keep current Y
}
// Lerp position (smooth movement)
c.x += (targetX - c.x) * 0.1;
c.y += (targetY - c.y) * 0.1;
// Draw
ctx.beginPath();
ctx.arc(c.x, c.y, 6, 0, Math.PI * 2);
ctx.fillStyle = c.color;
ctx.fill();
});
};
const renderChart = (results) => {
if (!chartInstance) {
chartInstance = echarts.init(chartContainer.value);
}
const xData = results.map(r => r.servers);
const costData = results.map(r => r.total_cost);
const waitData = results.map(r => r.avg_wait);
// Find min cost
const minCost = Math.min(...costData);
const optimalServers = results.find(r => r.total_cost === minCost).servers;
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
legend: {
data: ['总成本 ($)', '平均等待 (min)'],
textStyle: { color: '#94a3b8' }
},
xAxis: {
type: 'category',
data: xData,
name: '服务窗口数',
axisLine: { lineStyle: { color: '#475569' } },
axisLabel: { color: '#94a3b8' }
},
yAxis: [
{
type: 'value',
name: '总成本 ($)',
position: 'left',
axisLine: { lineStyle: { color: '#22c55e' } },
axisLabel: { color: '#22c55e' },
splitLine: { lineStyle: { color: '#334155' } }
},
{
type: 'value',
name: '平均等待 (min)',
position: 'right',
axisLine: { lineStyle: { color: '#3b82f6' } },
axisLabel: { color: '#3b82f6' },
splitLine: { show: false }
}
],
series: [
{
name: '总成本 ($)',
type: 'line',
data: costData,
smooth: true,
lineStyle: { color: '#22c55e', width: 3 },
itemStyle: { color: '#22c55e' },
markPoint: {
data: [
{ type: 'min', name: '最佳配置', itemStyle: { color: '#f59e0b' } }
]
}
},
{
name: '平均等待 (min)',
type: 'line',
yAxisIndex: 1,
data: waitData,
smooth: true,
lineStyle: { color: '#3b82f6', width: 2, type: 'dashed' },
itemStyle: { color: '#3b82f6' }
}
]
};
chartInstance.setOption(option);
window.addEventListener('resize', () => chartInstance.resize());
};
// --- Storage Logic ---
const loadSaved = () => {
const saved = localStorage.getItem('queue_sim_configs');
if (saved) savedConfigs.value = JSON.parse(saved);
};
const saveConfig = () => {
if (!saveName.value) return;
savedConfigs.value[saveName.value] = { ...params };
localStorage.setItem('queue_sim_configs', JSON.stringify(savedConfigs.value));
showSaveModal.value = false;
saveName.value = '';
alert('配置已保存');
};
const loadConfig = (name) => {
Object.assign(params, savedConfigs.value[name]);
showLoadModal.value = false;
};
const deleteConfig = (name) => {
delete savedConfigs.value[name];
localStorage.setItem('queue_sim_configs', JSON.stringify(savedConfigs.value));
};
// --- Import/Export Logic ---
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Client-side read for immediate update
const reader = new FileReader();
reader.onload = (e) => {
try {
const config = JSON.parse(e.target.result);
Object.assign(params, config);
alert('配置导入成功');
} catch (err) {
alert('无效的配置文件');
console.error(err);
}
};
reader.readAsText(file);
// Optional: Send to server to verify server-side logic (as requested)
const formData = new FormData();
formData.append('file', file);
try {
await fetch('/api/upload_config', {
method: 'POST',
body: formData
});
} catch (e) {
console.error("Server upload check failed, but client side worked:", e);
}
// Reset input
event.target.value = '';
};
const exportConfig = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(params, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "queue_config.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
onMounted(() => {
loadSaved();
});
return {
params,
metrics,
loading,
currentTime,
simCanvas,
chartContainer,
runSimulation,
runOptimization,
showSaveModal,
showLoadModal,
saveName,
savedConfigs,
saveConfig,
loadConfig,
deleteConfig,
fileInput,
triggerUpload,
handleFileUpload,
exportConfig
};
}
}).mount('#app');
</script>
</body>
</html>