Trae Assistant commited on
Commit
6c4d394
·
0 Parent(s):

Initial commit: Enhanced Queue Strategy Lab with Import/Export and Error Handling

Browse files
Files changed (6) hide show
  1. .gitignore +6 -0
  2. Dockerfile +16 -0
  3. README.md +40 -0
  4. app.py +163 -0
  5. requirements.txt +4 -0
  6. templates/index.html +661 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ venv/
5
+ .env
6
+ .git
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (good practice for HF Spaces)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 智能排队策略实验室 (Queue Strategy Lab)
3
+ emoji: 🚦
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 基于SimPy的离散事件仿真工具,优化服务窗口配置与成本分析。
9
+ ---
10
+
11
+ # 智能排队策略实验室 (Queue Strategy Lab)
12
+
13
+ 这是一个专业的离散事件仿真(Discrete Event Simulation, DES)工具,旨在帮助服务型企业(如银行、医院、客服中心、零售店)优化服务窗口配置,平衡客户等待时间和运营成本。
14
+
15
+ ## 核心功能
16
+
17
+ 1. **多场景仿真引擎**:基于 `SimPy` 强大的离散事件仿真能力,精确模拟排队过程(M/M/c 模型及其变体)。
18
+ 2. **成本优化分析**:输入“客户等待时间成本”和“服务员时薪”,自动计算最优服务窗口数量,绘制成本曲线。
19
+ 3. **实时动态可视化**:通过 Vue 3 + Canvas 实时展示排队动画,直观感受拥堵情况。
20
+ 4. **数据洞察仪表盘**:提供平均等待时间、服务员利用率、队列长度分布等关键指标。
21
+ 5. **资产管理**:支持保存和加载不同的仿真场景配置(如“早高峰”、“周末促销”)。
22
+
23
+ ## 技术栈
24
+
25
+ - **Backend**: Python 3.11, Flask, SimPy (Simulation), NumPy (Stats)
26
+ - **Frontend**: Vue 3, Tailwind CSS, ECharts, Canvas
27
+ - **Deployment**: Docker
28
+
29
+ ## 使用说明
30
+
31
+ 1. 在左侧面板设置仿真参数(到达率、服务时间、成本参数等)。
32
+ 2. 点击“开始仿真”查看动画和实时数据。
33
+ 3. 点击“运行优化分析”获取基于成本的最佳配置建议。
34
+
35
+ ## 商业应用场景
36
+
37
+ - **零售门店**:决定收银台开放数量。
38
+ - **银行网点**:优化柜员排班。
39
+ - **呼叫中心**:预测坐席需求与SLA达标率。
40
+ - **主题公园**:设施吞吐量评估。
app.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import simpy
4
+ import numpy as np
5
+ from flask import Flask, render_template, jsonify, request, send_from_directory
6
+
7
+ app = Flask(__name__, template_folder='templates')
8
+
9
+ # Configuration
10
+ MAX_SIM_TIME = 480 # 8 hours in minutes for analysis
11
+ SEED = 42
12
+
13
+ @app.route('/')
14
+ def index():
15
+ return render_template('index.html')
16
+
17
+ @app.errorhandler(404)
18
+ def page_not_found(e):
19
+ return render_template('index.html'), 404
20
+
21
+ @app.errorhandler(500)
22
+ def internal_server_error(e):
23
+ return jsonify(error="Internal Server Error", message=str(e)), 500
24
+
25
+ @app.route('/api/upload_config', methods=['POST'])
26
+ def upload_config():
27
+ try:
28
+ if 'file' not in request.files:
29
+ return jsonify({'error': 'No file part'}), 400
30
+ file = request.files['file']
31
+ if file.filename == '':
32
+ return jsonify({'error': 'No selected file'}), 400
33
+ if file:
34
+ import json
35
+ content = json.load(file)
36
+ return jsonify({'message': 'Config uploaded successfully', 'config': content})
37
+ except Exception as e:
38
+ return jsonify({'error': str(e)}), 500
39
+
40
+
41
+ class CallCenter:
42
+ def __init__(self, env, num_employees, service_time_avg, service_time_std):
43
+ self.env = env
44
+ self.staff = simpy.Resource(env, num_employees)
45
+ self.service_time_avg = service_time_avg
46
+ self.service_time_std = service_time_std
47
+ self.wait_times = []
48
+ self.service_times = []
49
+ self.utilization_log = []
50
+ self.events_trace = [] # For visualization: {'time': t, 'type': '...', 'id': ...}
51
+
52
+ def support(self, customer):
53
+ arrival_time = self.env.now
54
+ self.events_trace.append({'time': arrival_time, 'type': 'arrival', 'id': customer})
55
+
56
+ with self.staff.request() as request:
57
+ yield request
58
+
59
+ wait = self.env.now - arrival_time
60
+ self.wait_times.append(wait)
61
+ self.events_trace.append({'time': self.env.now, 'type': 'start', 'id': customer, 'wait': wait})
62
+
63
+ # Service time (Normal distribution, clipped at 0.5 min)
64
+ service_duration = max(0.5, random.gauss(self.service_time_avg, self.service_time_std))
65
+ yield self.env.timeout(service_duration)
66
+
67
+ self.service_times.append(service_duration)
68
+ self.events_trace.append({'time': self.env.now, 'type': 'finish', 'id': customer})
69
+
70
+ def customer_generator(env, center, arrival_rate):
71
+ """
72
+ arrival_rate: Customers per hour
73
+ """
74
+ i = 0
75
+ while True:
76
+ # Inter-arrival time (Exponential distribution)
77
+ yield env.timeout(random.expovariate(arrival_rate / 60.0))
78
+ i += 1
79
+ env.process(center.support(f'C{i}'))
80
+
81
+ @app.route('/api/simulate', methods=['POST'])
82
+ def simulate():
83
+ data = request.json
84
+
85
+ # Parameters
86
+ arrival_rate = float(data.get('arrival_rate', 60)) # Cust/hr
87
+ service_time = float(data.get('service_time', 5)) # Avg min
88
+ service_std = float(data.get('service_std', 1)) # Std Dev min
89
+ num_servers = int(data.get('num_servers', 3))
90
+ duration = float(data.get('duration', 60)) # Minutes to simulate
91
+
92
+ random.seed(SEED)
93
+ env = simpy.Environment()
94
+ center = CallCenter(env, num_servers, service_time, service_std)
95
+ env.process(customer_generator(env, center, arrival_rate))
96
+ env.run(until=duration)
97
+
98
+ # Calculate stats
99
+ avg_wait = np.mean(center.wait_times) if center.wait_times else 0
100
+ max_wait = np.max(center.wait_times) if center.wait_times else 0
101
+ served_count = len(center.service_times)
102
+
103
+ # Utilization estimate (Total Service Time / (Num Servers * Duration))
104
+ total_service = sum(center.service_times)
105
+ utilization = (total_service / (num_servers * duration)) * 100 if num_servers > 0 else 0
106
+
107
+ return jsonify({
108
+ 'metrics': {
109
+ 'avg_wait': round(avg_wait, 2),
110
+ 'max_wait': round(max_wait, 2),
111
+ 'served': served_count,
112
+ 'utilization': round(utilization, 2)
113
+ },
114
+ 'trace': center.events_trace
115
+ })
116
+
117
+ @app.route('/api/optimize', methods=['POST'])
118
+ def optimize():
119
+ data = request.json
120
+
121
+ arrival_rate = float(data.get('arrival_rate', 60))
122
+ service_time = float(data.get('service_time', 5))
123
+ service_std = float(data.get('service_std', 1))
124
+
125
+ cost_per_server_hr = float(data.get('cost_server', 20)) # $20/hr
126
+ cost_per_wait_hr = float(data.get('cost_wait', 50)) # $50/hr (Value of customer time/frustration)
127
+
128
+ results = []
129
+
130
+ # Test 1 to 15 servers
131
+ for n in range(1, 16):
132
+ random.seed(SEED) # Reset seed for fair comparison
133
+ env = simpy.Environment()
134
+ center = CallCenter(env, n, service_time, service_std)
135
+ env.process(customer_generator(env, center, arrival_rate))
136
+ env.run(until=480) # 8 hours
137
+
138
+ avg_wait_min = np.mean(center.wait_times) if center.wait_times else 0
139
+ total_wait_hours = sum(center.wait_times) / 60.0
140
+
141
+ # Total Cost = (Server Cost * 8h) + (Total Wait Hours * Wait Cost)
142
+ server_cost = n * cost_per_server_hr * 8
143
+ wait_cost = total_wait_hours * cost_per_wait_hr
144
+ total_cost = server_cost + wait_cost
145
+
146
+ utilization = (sum(center.service_times) / (n * 480)) * 100
147
+
148
+ results.append({
149
+ 'servers': n,
150
+ 'total_cost': round(total_cost, 2),
151
+ 'server_cost': round(server_cost, 2),
152
+ 'wait_cost': round(wait_cost, 2),
153
+ 'avg_wait': round(avg_wait_min, 2),
154
+ 'utilization': round(utilization, 1)
155
+ })
156
+
157
+ # Optimization: if cost starts increasing significantly and wait is low, we can stop,
158
+ # but let's run all 15 for the chart.
159
+
160
+ return jsonify({'results': results})
161
+
162
+ if __name__ == '__main__':
163
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask
2
+ simpy
3
+ numpy
4
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>智能排队策略实验室 | Queue Strategy Lab</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
10
+ <!-- Font Awesome -->
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
12
+ <style>
13
+ /* Glassmorphism */
14
+ .glass {
15
+ background: rgba(30, 41, 59, 0.7);
16
+ backdrop-filter: blur(10px);
17
+ border: 1px solid rgba(255, 255, 255, 0.1);
18
+ }
19
+ body {
20
+ background-color: #0f172a; /* Slate 900 */
21
+ color: #e2e8f0;
22
+ }
23
+ .input-group label {
24
+ display: block;
25
+ font-size: 0.875rem;
26
+ color: #94a3b8;
27
+ margin-bottom: 0.25rem;
28
+ }
29
+ .input-group input {
30
+ width: 100%;
31
+ background: #1e293b;
32
+ border: 1px solid #334155;
33
+ color: white;
34
+ padding: 0.5rem;
35
+ border-radius: 0.375rem;
36
+ }
37
+ .btn-primary {
38
+ background: #4f46e5;
39
+ color: white;
40
+ padding: 0.5rem 1rem;
41
+ border-radius: 0.375rem;
42
+ font-weight: 600;
43
+ transition: all 0.2s;
44
+ }
45
+ .btn-primary:hover {
46
+ background: #4338ca;
47
+ }
48
+ .btn-secondary {
49
+ background: #334155;
50
+ color: white;
51
+ padding: 0.5rem 1rem;
52
+ border-radius: 0.375rem;
53
+ font-weight: 600;
54
+ }
55
+ .btn-secondary:hover {
56
+ background: #475569;
57
+ }
58
+ canvas {
59
+ width: 100%;
60
+ height: 300px;
61
+ background: #1e293b;
62
+ border-radius: 0.5rem;
63
+ }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div id="app" class="min-h-screen flex flex-col">
68
+ <!-- Header -->
69
+ <header class="glass p-4 border-b border-gray-700 flex justify-between items-center z-10">
70
+ <div class="flex items-center gap-3">
71
+ <div class="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center text-xl">🚦</div>
72
+ <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-cyan-400">
73
+ 智能排队策略实验室
74
+ </h1>
75
+ </div>
76
+ <div class="flex gap-2">
77
+ <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".json">
78
+ <button @click="triggerUpload" class="btn-secondary text-sm" title="导入配置">
79
+ <i class="fas fa-file-upload mr-1"></i> 导入
80
+ </button>
81
+ <button @click="exportConfig" class="btn-secondary text-sm" title="导出配置">
82
+ <i class="fas fa-file-download mr-1"></i> 导出
83
+ </button>
84
+ <button @click="showSaveModal = true" class="btn-secondary text-sm">
85
+ <i class="fas fa-save mr-1"></i> 保存
86
+ </button>
87
+ <button @click="showLoadModal = true" class="btn-secondary text-sm">
88
+ <i class="fas fa-folder-open mr-1"></i> 加载
89
+ </button>
90
+ </div>
91
+ </header>
92
+
93
+ <main class="flex-1 flex overflow-hidden">
94
+ <!-- Sidebar Controls -->
95
+ <aside class="w-80 glass border-r border-gray-700 flex flex-col overflow-y-auto p-4 gap-6">
96
+
97
+ <!-- Section: Simulation Params -->
98
+ <div>
99
+ <h3 class="text-lg font-semibold text-indigo-400 mb-3"><i class="fas fa-sliders-h mr-2"></i>仿真参数</h3>
100
+ <div class="space-y-3">
101
+ <div class="input-group">
102
+ <label>客户到达率 (人/小时)</label>
103
+ <input type="number" v-model.number="params.arrival_rate" min="1" max="1000">
104
+ </div>
105
+ <div class="input-group">
106
+ <label>平均服务时间 (分钟)</label>
107
+ <input type="number" v-model.number="params.service_time" min="0.1" step="0.1">
108
+ </div>
109
+ <div class="input-group">
110
+ <label>服务时间波动 (标准差)</label>
111
+ <input type="number" v-model.number="params.service_std" min="0" step="0.1">
112
+ </div>
113
+ <div class="input-group">
114
+ <label>服务窗口数量</label>
115
+ <input type="number" v-model.number="params.num_servers" min="1" max="50">
116
+ </div>
117
+ <div class="input-group">
118
+ <label>仿真时长 (分钟)</label>
119
+ <input type="number" v-model.number="params.duration" min="10" max="1440">
120
+ </div>
121
+ </div>
122
+ <button @click="runSimulation" class="btn-primary w-full mt-4">
123
+ <i class="fas fa-play mr-2"></i> 开始仿真演示
124
+ </button>
125
+ </div>
126
+
127
+ <!-- Section: Cost Params -->
128
+ <div class="border-t border-gray-700 pt-4">
129
+ <h3 class="text-lg font-semibold text-green-400 mb-3"><i class="fas fa-chart-line mr-2"></i>成本优化</h3>
130
+ <div class="space-y-3">
131
+ <div class="input-group">
132
+ <label>服务员时薪 ($/小时)</label>
133
+ <input type="number" v-model.number="params.cost_server" min="1">
134
+ </div>
135
+ <div class="input-group">
136
+ <label>客户等待成本 ($/小时)</label>
137
+ <input type="number" v-model.number="params.cost_wait" min="1">
138
+ <p class="text-xs text-gray-500 mt-1">客户因等待产生的隐性损失或不满折现</p>
139
+ </div>
140
+ </div>
141
+ <button @click="runOptimization" class="btn-primary w-full mt-4 bg-green-600 hover:bg-green-700">
142
+ <i class="fas fa-calculator mr-2"></i> 运行优化分析
143
+ </button>
144
+ </div>
145
+ </aside>
146
+
147
+ <!-- Main Content -->
148
+ <div class="flex-1 flex flex-col overflow-y-auto bg-slate-900 p-6 gap-6">
149
+
150
+ <!-- Top: Animation & Live Stats -->
151
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[350px]">
152
+ <!-- Visualizer -->
153
+ <div class="lg:col-span-2 glass rounded-xl p-4 flex flex-col relative">
154
+ <div class="flex justify-between items-center mb-2">
155
+ <h3 class="font-semibold text-gray-300">实时队列视图</h3>
156
+ <div class="text-sm text-indigo-400">
157
+ 时间: <span class="font-mono">${ currentTime.toFixed(1) }</span> min
158
+ </div>
159
+ </div>
160
+ <canvas ref="simCanvas" class="flex-1 w-full rounded bg-slate-800 border border-slate-700"></canvas>
161
+ <!-- Legend -->
162
+ <div class="absolute bottom-6 left-6 flex gap-4 text-xs text-gray-400">
163
+ <div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-blue-500"></div> 排队中</div>
164
+ <div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-green-500"></div> 服务中</div>
165
+ <div class="flex items-center gap-1"><div class="w-3 h-3 rounded-full bg-gray-500"></div> 空闲窗口</div>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- Live Stats -->
170
+ <div class="glass rounded-xl p-4 flex flex-col justify-center gap-4">
171
+ <h3 class="font-semibold text-gray-300 border-b border-gray-700 pb-2">仿真统计结果</h3>
172
+
173
+ <div class="grid grid-cols-2 gap-4">
174
+ <div class="bg-slate-800 p-3 rounded-lg text-center">
175
+ <div class="text-xs text-gray-500">平均等待</div>
176
+ <div class="text-2xl font-bold text-indigo-400">${ metrics.avg_wait } <span class="text-xs">min</span></div>
177
+ </div>
178
+ <div class="bg-slate-800 p-3 rounded-lg text-center">
179
+ <div class="text-xs text-gray-500">最大等待</div>
180
+ <div class="text-2xl font-bold text-red-400">${ metrics.max_wait } <span class="text-xs">min</span></div>
181
+ </div>
182
+ <div class="bg-slate-800 p-3 rounded-lg text-center">
183
+ <div class="text-xs text-gray-500">已服务人数</div>
184
+ <div class="text-2xl font-bold text-green-400">${ metrics.served }</div>
185
+ </div>
186
+ <div class="bg-slate-800 p-3 rounded-lg text-center">
187
+ <div class="text-xs text-gray-500">窗口利用率</div>
188
+ <div class="text-2xl font-bold text-yellow-400">${ metrics.utilization }<span class="text-sm">%</span></div>
189
+ </div>
190
+ </div>
191
+
192
+ <div v-if="loading" class="text-center text-indigo-400 animate-pulse">
193
+ <i class="fas fa-circle-notch fa-spin mr-2"></i> 计算中...
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Bottom: Charts -->
199
+ <div class="glass rounded-xl p-4 flex-1 min-h-[400px]">
200
+ <h3 class="font-semibold text-gray-300 mb-4">成本优化分析 (服务窗口数 vs 总成本)</h3>
201
+ <div ref="chartContainer" class="w-full h-[350px]"></div>
202
+ </div>
203
+
204
+ </div>
205
+ </main>
206
+
207
+ <!-- Save Modal -->
208
+ <div v-if="showSaveModal" class="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50">
209
+ <div class="glass p-6 rounded-xl w-96">
210
+ <h3 class="text-xl font-bold mb-4">保存配置</h3>
211
+ <input v-model="saveName" placeholder="输入配置名称 (如: 早高峰)" class="w-full bg-slate-800 p-2 rounded mb-4 text-white">
212
+ <div class="flex justify-end gap-2">
213
+ <button @click="showSaveModal = false" class="btn-secondary">取消</button>
214
+ <button @click="saveConfig" class="btn-primary">保存</button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Load Modal -->
220
+ <div v-if="showLoadModal" class="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50">
221
+ <div class="glass p-6 rounded-xl w-96">
222
+ <h3 class="text-xl font-bold mb-4">加载配置</h3>
223
+ <ul class="space-y-2 max-h-60 overflow-y-auto mb-4">
224
+ <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)">
225
+ <span>${ name }</span>
226
+ <button @click.stop="deleteConfig(name)" class="text-red-400 hover:text-red-300"><i class="fas fa-trash"></i></button>
227
+ </li>
228
+ <li v-if="Object.keys(savedConfigs).length === 0" class="text-gray-500 text-center">暂无保存的配置</li>
229
+ </ul>
230
+ <div class="flex justify-end gap-2">
231
+ <button @click="showLoadModal = false" class="btn-secondary">关闭</button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ </div>
237
+
238
+ <script>
239
+ const { createApp, ref, onMounted, reactive, watch, nextTick } = Vue;
240
+
241
+ createApp({
242
+ delimiters: ['${', '}'],
243
+ setup() {
244
+ // State
245
+ const params = reactive({
246
+ arrival_rate: 120,
247
+ service_time: 2.0,
248
+ service_std: 0.5,
249
+ num_servers: 5,
250
+ duration: 60,
251
+ cost_server: 20,
252
+ cost_wait: 60
253
+ });
254
+
255
+ const metrics = reactive({ avg_wait: 0, max_wait: 0, served: 0, utilization: 0 });
256
+ const loading = ref(false);
257
+ const currentTime = ref(0);
258
+
259
+ // Modals
260
+ const showSaveModal = ref(false);
261
+ const showLoadModal = ref(false);
262
+ const saveName = ref('');
263
+ const savedConfigs = ref({});
264
+ const fileInput = ref(null);
265
+
266
+ // Canvas & Chart Refs
267
+ const simCanvas = ref(null);
268
+ const chartContainer = ref(null);
269
+ let chartInstance = null;
270
+ let animationFrame = null;
271
+
272
+ // Simulation State
273
+ let traceData = [];
274
+ let playbackSpeed = 1; // Multiplier
275
+ let customers = []; // { id, state: 'queue'|'service', serverIdx, x, y, targetX, targetY }
276
+ let servers = []; // { id, busy: false, customerId: null }
277
+
278
+ // --- Methods ---
279
+
280
+ const runSimulation = async () => {
281
+ loading.value = true;
282
+ try {
283
+ const res = await fetch('/api/simulate', {
284
+ method: 'POST',
285
+ headers: {'Content-Type': 'application/json'},
286
+ body: JSON.stringify(params)
287
+ });
288
+ const data = await res.json();
289
+
290
+ // Update metrics
291
+ Object.assign(metrics, data.metrics);
292
+
293
+ // Start Visualization
294
+ traceData = data.trace;
295
+ startAnimation();
296
+
297
+ } catch (e) {
298
+ console.error(e);
299
+ alert('仿真请求失败');
300
+ } finally {
301
+ loading.value = false;
302
+ }
303
+ };
304
+
305
+ const runOptimization = async () => {
306
+ loading.value = true;
307
+ try {
308
+ const res = await fetch('/api/optimize', {
309
+ method: 'POST',
310
+ headers: {'Content-Type': 'application/json'},
311
+ body: JSON.stringify(params)
312
+ });
313
+ const data = await res.json();
314
+ renderChart(data.results);
315
+ } catch (e) {
316
+ console.error(e);
317
+ alert('优化请求失败');
318
+ } finally {
319
+ loading.value = false;
320
+ }
321
+ };
322
+
323
+ // --- Visualization Logic ---
324
+
325
+ const startAnimation = () => {
326
+ if (animationFrame) cancelAnimationFrame(animationFrame);
327
+
328
+ // Reset
329
+ currentTime.value = 0;
330
+ customers = [];
331
+ servers = Array.from({length: params.num_servers}, (_, i) => ({ id: i, busy: false, customerId: null }));
332
+
333
+ // Canvas Setup
334
+ const canvas = simCanvas.value;
335
+ const ctx = canvas.getContext('2d');
336
+ const dpr = window.devicePixelRatio || 1;
337
+ const rect = canvas.getBoundingClientRect();
338
+ canvas.width = rect.width * dpr;
339
+ canvas.height = rect.height * dpr;
340
+ ctx.scale(dpr, dpr);
341
+
342
+ const width = rect.width;
343
+ const height = rect.height;
344
+
345
+ let lastFrameTime = performance.now();
346
+ let traceIndex = 0;
347
+
348
+ const animate = (now) => {
349
+ const dt = (now - lastFrameTime) / 1000; // seconds
350
+ lastFrameTime = now;
351
+
352
+ // Advance simulation time (1 real sec = 1 sim min * speed)
353
+ // Let's make it fast: 1 real sec = 10 sim mins
354
+ const simSpeed = 5.0;
355
+ currentTime.value += dt * simSpeed;
356
+
357
+ // Process events up to currentTime
358
+ while (traceIndex < traceData.length && traceData[traceIndex].time <= currentTime.value) {
359
+ const event = traceData[traceIndex];
360
+ processEvent(event);
361
+ traceIndex++;
362
+ }
363
+
364
+ // Draw
365
+ drawScene(ctx, width, height);
366
+
367
+ if (currentTime.value < params.duration || customers.length > 0) {
368
+ animationFrame = requestAnimationFrame(animate);
369
+ }
370
+ };
371
+
372
+ requestAnimationFrame(animate);
373
+ };
374
+
375
+ const processEvent = (event) => {
376
+ if (event.type === 'arrival') {
377
+ customers.push({
378
+ id: event.id,
379
+ state: 'queue',
380
+ x: 50, // Start left
381
+ y: 150,
382
+ color: '#3b82f6' // Blue
383
+ });
384
+ } else if (event.type === 'start') {
385
+ // Find customer
386
+ const cust = customers.find(c => c.id === event.id);
387
+ if (cust) {
388
+ cust.state = 'service';
389
+ cust.color = '#22c55e'; // Green
390
+ // Find free server
391
+ const server = servers.find(s => !s.busy);
392
+ if (server) {
393
+ server.busy = true;
394
+ server.customerId = cust.id;
395
+ cust.serverIdx = server.id;
396
+ }
397
+ }
398
+ } else if (event.type === 'finish') {
399
+ const cust = customers.find(c => c.id === event.id);
400
+ if (cust) {
401
+ // Mark for removal or move to exit
402
+ cust.state = 'exit';
403
+ // Free server
404
+ if (cust.serverIdx !== undefined) {
405
+ const server = servers[cust.serverIdx];
406
+ if (server) {
407
+ server.busy = false;
408
+ server.customerId = null;
409
+ }
410
+ }
411
+ }
412
+ }
413
+ };
414
+
415
+ const drawScene = (ctx, w, h) => {
416
+ ctx.clearRect(0, 0, w, h);
417
+
418
+ // Draw Server Booths (Right side)
419
+ const serverX = w - 100;
420
+ const serverGap = h / (params.num_servers + 1);
421
+
422
+ servers.forEach((s, i) => {
423
+ const y = (i + 1) * serverGap;
424
+ ctx.fillStyle = s.busy ? '#1e293b' : '#334155'; // Darker if busy (occupied)
425
+ ctx.strokeStyle = s.busy ? '#22c55e' : '#64748b';
426
+ ctx.lineWidth = 2;
427
+ ctx.beginPath();
428
+ ctx.roundRect(serverX, y - 15, 60, 30, 5);
429
+ ctx.fill();
430
+ ctx.stroke();
431
+
432
+ ctx.fillStyle = '#94a3b8';
433
+ ctx.font = '12px sans-serif';
434
+ ctx.fillText(`窗口 ${i+1}`, serverX + 10, y + 5);
435
+ });
436
+
437
+ // Draw Queue Area (Left)
438
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
439
+ ctx.fillRect(20, 20, w - 150, h - 40);
440
+
441
+ // Draw Customers
442
+ // Queue positions logic: organize them in lines
443
+ let queueIdx = 0;
444
+
445
+ // Remove exited customers smoothly
446
+ customers = customers.filter(c => !(c.state === 'exit' && c.x > w));
447
+
448
+ customers.forEach(c => {
449
+ let targetX, targetY;
450
+
451
+ if (c.state === 'queue') {
452
+ // Simple wrapping queue
453
+ const col = Math.floor(queueIdx / 10);
454
+ const row = queueIdx % 10;
455
+ targetX = 50 + col * 20;
456
+ targetY = 50 + row * 20;
457
+ queueIdx++;
458
+ } else if (c.state === 'service') {
459
+ // Move to assigned server
460
+ const sY = (c.serverIdx + 1) * serverGap;
461
+ targetX = serverX + 30;
462
+ targetY = sY;
463
+ } else if (c.state === 'exit') {
464
+ targetX = w + 50;
465
+ targetY = c.y; // Keep current Y
466
+ }
467
+
468
+ // Lerp position (smooth movement)
469
+ c.x += (targetX - c.x) * 0.1;
470
+ c.y += (targetY - c.y) * 0.1;
471
+
472
+ // Draw
473
+ ctx.beginPath();
474
+ ctx.arc(c.x, c.y, 6, 0, Math.PI * 2);
475
+ ctx.fillStyle = c.color;
476
+ ctx.fill();
477
+ });
478
+ };
479
+
480
+ const renderChart = (results) => {
481
+ if (!chartInstance) {
482
+ chartInstance = echarts.init(chartContainer.value);
483
+ }
484
+
485
+ const xData = results.map(r => r.servers);
486
+ const costData = results.map(r => r.total_cost);
487
+ const waitData = results.map(r => r.avg_wait);
488
+
489
+ // Find min cost
490
+ const minCost = Math.min(...costData);
491
+ const optimalServers = results.find(r => r.total_cost === minCost).servers;
492
+
493
+ const option = {
494
+ backgroundColor: 'transparent',
495
+ tooltip: {
496
+ trigger: 'axis',
497
+ axisPointer: { type: 'cross' }
498
+ },
499
+ legend: {
500
+ data: ['总成本 ($)', '平均等待 (min)'],
501
+ textStyle: { color: '#94a3b8' }
502
+ },
503
+ xAxis: {
504
+ type: 'category',
505
+ data: xData,
506
+ name: '服务窗口数',
507
+ axisLine: { lineStyle: { color: '#475569' } },
508
+ axisLabel: { color: '#94a3b8' }
509
+ },
510
+ yAxis: [
511
+ {
512
+ type: 'value',
513
+ name: '总成本 ($)',
514
+ position: 'left',
515
+ axisLine: { lineStyle: { color: '#22c55e' } },
516
+ axisLabel: { color: '#22c55e' },
517
+ splitLine: { lineStyle: { color: '#334155' } }
518
+ },
519
+ {
520
+ type: 'value',
521
+ name: '平均等待 (min)',
522
+ position: 'right',
523
+ axisLine: { lineStyle: { color: '#3b82f6' } },
524
+ axisLabel: { color: '#3b82f6' },
525
+ splitLine: { show: false }
526
+ }
527
+ ],
528
+ series: [
529
+ {
530
+ name: '总成本 ($)',
531
+ type: 'line',
532
+ data: costData,
533
+ smooth: true,
534
+ lineStyle: { color: '#22c55e', width: 3 },
535
+ itemStyle: { color: '#22c55e' },
536
+ markPoint: {
537
+ data: [
538
+ { type: 'min', name: '最佳配置', itemStyle: { color: '#f59e0b' } }
539
+ ]
540
+ }
541
+ },
542
+ {
543
+ name: '平均等待 (min)',
544
+ type: 'line',
545
+ yAxisIndex: 1,
546
+ data: waitData,
547
+ smooth: true,
548
+ lineStyle: { color: '#3b82f6', width: 2, type: 'dashed' },
549
+ itemStyle: { color: '#3b82f6' }
550
+ }
551
+ ]
552
+ };
553
+ chartInstance.setOption(option);
554
+ window.addEventListener('resize', () => chartInstance.resize());
555
+ };
556
+
557
+ // --- Storage Logic ---
558
+
559
+ const loadSaved = () => {
560
+ const saved = localStorage.getItem('queue_sim_configs');
561
+ if (saved) savedConfigs.value = JSON.parse(saved);
562
+ };
563
+
564
+ const saveConfig = () => {
565
+ if (!saveName.value) return;
566
+ savedConfigs.value[saveName.value] = { ...params };
567
+ localStorage.setItem('queue_sim_configs', JSON.stringify(savedConfigs.value));
568
+ showSaveModal.value = false;
569
+ saveName.value = '';
570
+ alert('配置已保存');
571
+ };
572
+
573
+ const loadConfig = (name) => {
574
+ Object.assign(params, savedConfigs.value[name]);
575
+ showLoadModal.value = false;
576
+ };
577
+
578
+ const deleteConfig = (name) => {
579
+ delete savedConfigs.value[name];
580
+ localStorage.setItem('queue_sim_configs', JSON.stringify(savedConfigs.value));
581
+ };
582
+
583
+ // --- Import/Export Logic ---
584
+ const triggerUpload = () => {
585
+ fileInput.value.click();
586
+ };
587
+
588
+ const handleFileUpload = async (event) => {
589
+ const file = event.target.files[0];
590
+ if (!file) return;
591
+
592
+ // Client-side read for immediate update
593
+ const reader = new FileReader();
594
+ reader.onload = (e) => {
595
+ try {
596
+ const config = JSON.parse(e.target.result);
597
+ Object.assign(params, config);
598
+ alert('配置导入成功');
599
+ } catch (err) {
600
+ alert('无效的配置文件');
601
+ console.error(err);
602
+ }
603
+ };
604
+ reader.readAsText(file);
605
+
606
+ // Optional: Send to server to verify server-side logic (as requested)
607
+ const formData = new FormData();
608
+ formData.append('file', file);
609
+ try {
610
+ await fetch('/api/upload_config', {
611
+ method: 'POST',
612
+ body: formData
613
+ });
614
+ } catch (e) {
615
+ console.error("Server upload check failed, but client side worked:", e);
616
+ }
617
+
618
+ // Reset input
619
+ event.target.value = '';
620
+ };
621
+
622
+ const exportConfig = () => {
623
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(params, null, 2));
624
+ const downloadAnchorNode = document.createElement('a');
625
+ downloadAnchorNode.setAttribute("href", dataStr);
626
+ downloadAnchorNode.setAttribute("download", "queue_config.json");
627
+ document.body.appendChild(downloadAnchorNode);
628
+ downloadAnchorNode.click();
629
+ downloadAnchorNode.remove();
630
+ };
631
+
632
+ onMounted(() => {
633
+ loadSaved();
634
+ });
635
+
636
+ return {
637
+ params,
638
+ metrics,
639
+ loading,
640
+ currentTime,
641
+ simCanvas,
642
+ chartContainer,
643
+ runSimulation,
644
+ runOptimization,
645
+ showSaveModal,
646
+ showLoadModal,
647
+ saveName,
648
+ savedConfigs,
649
+ saveConfig,
650
+ loadConfig,
651
+ deleteConfig,
652
+ fileInput,
653
+ triggerUpload,
654
+ handleFileUpload,
655
+ exportConfig
656
+ };
657
+ }
658
+ }).mount('#app');
659
+ </script>
660
+ </body>
661
+ </html>