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

Initial commit with enhanced functionality

Browse files
Files changed (6) hide show
  1. .gitignore +5 -0
  2. Dockerfile +15 -0
  3. README.md +55 -0
  4. app.py +305 -0
  5. requirements.txt +5 -0
  6. templates/index.html +350 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .env
5
+ venv/
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH
14
+
15
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Seating Chart Solver
3
+ emoji: 🪑
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 基于模拟退火算法的智能活动排座与可视化系统
9
+ ---
10
+
11
+ # Seating Chart Solver (智能排座专家)
12
+
13
+ 这是一个专为大型活动(婚礼、晚宴、会议)设计的智能排座系统。它利用**模拟退火算法 (Simulated Annealing)**,在满足各种复杂约束条件(如“必须坐在一起”、“不能坐在一起”)的同时,自动优化宾客的座位安排,最大化整体满意度。
14
+
15
+ ## 核心功能
16
+
17
+ * **智能优化引擎**: 一键自动计算最佳座位安排,解决 NP-Hard 排座问题。
18
+ * **复杂约束管理**: 支持定义“亲密关系”(权重加分)和“冲突关系”(权重扣分)。
19
+ * **可视化布局**: 直观的圆形餐桌视图,实时显示入座情况。
20
+ * **资产导出**: 支持将最终排座方案导出为高清图片 (PNG) 或 PDF 文档,直接用于打印或分享。
21
+ * **多维度评分**: 实时反馈当前方案的“和谐度”评分。
22
+
23
+ ## 解决痛点
24
+
25
+ 活动策划中最头疼的环节往往是排座位:
26
+ * 如何让关系好的人坐在一起?
27
+ * 如何避免死对头同桌?
28
+ * 如何平衡每桌的人数?
29
+ 本工具通过算法自动化解决这些问题,将耗时数小时的工作缩短为几秒钟。
30
+
31
+ ## 技术栈
32
+
33
+ * **算法**: Python (Simulated Annealing), NumPy
34
+ * **后端**: Flask
35
+ * **前端**: Vue 3, Element Plus, TailwindCSS
36
+ * **导出**: html2canvas, jsPDF
37
+ * **部署**: Docker (Python 3.11 Slim)
38
+
39
+ ## 算法原理
40
+
41
+ 系统将排座问题建模为组合优化问题:
42
+ 1. **目标函数**: $Score = \sum (Relation_{i,j} \times SameTable_{i,j}) - \sum (Conflict_{i,j} \times SameTable_{i,j}) + GroupBonus$
43
+ 2. **寻优策略**: 使用模拟退火算法,允许以一定概率接受较差解以跳出局部最优,最终收敛到全局近似最优解。
44
+
45
+ ## 运行方式
46
+
47
+ ```bash
48
+ # 构建
49
+ docker build -t seating-solver .
50
+
51
+ # 运行
52
+ docker run -p 7860:7860 seating-solver
53
+ ```
54
+
55
+ 访问 `http://localhost:7860` 开始使用。
app.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import math
3
+ import numpy as np
4
+ import pandas as pd
5
+ import io
6
+ from flask import Flask, jsonify, request, render_template
7
+
8
+ app = Flask(__name__)
9
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB limit
10
+
11
+ # 模拟退火算法参数
12
+ INITIAL_TEMP = 1000
13
+ COOLING_RATE = 0.995
14
+ MIN_TEMP = 1
15
+ MAX_ITERATIONS = 5000
16
+
17
+ class SeatingSolver:
18
+ def __init__(self, guests, tables, constraints):
19
+ """
20
+ guests: list of {id, name, group, tags}
21
+ tables: list of {id, name, capacity}
22
+ constraints: list of {guest_id_1, guest_id_2, type: 'must'|'cannot', weight}
23
+ """
24
+ self.guests = guests
25
+ self.tables = tables
26
+ self.constraints = constraints
27
+ self.guest_map = {g['id']: g for g in guests}
28
+
29
+ # 预处理关系矩阵
30
+ self.relation_matrix = {} # (id1, id2) -> score
31
+ for c in constraints:
32
+ key = tuple(sorted([c['g1'], c['g2']]))
33
+ w = c.get('weight', 10)
34
+ if c['type'] == 'must':
35
+ self.relation_matrix[key] = w
36
+ elif c['type'] == 'cannot':
37
+ self.relation_matrix[key] = -w
38
+
39
+ def solve(self):
40
+ # 初始解:随机分配
41
+ current_solution = self._initial_solution()
42
+ current_score = self._calculate_score(current_solution)
43
+
44
+ best_solution = current_solution.copy()
45
+ best_score = current_score
46
+
47
+ temp = INITIAL_TEMP
48
+
49
+ for i in range(MAX_ITERATIONS):
50
+ if temp < MIN_TEMP:
51
+ break
52
+
53
+ # 产生新解:随机移动或交换
54
+ new_solution = self._neighbor_solution(current_solution)
55
+ new_score = self._calculate_score(new_solution)
56
+
57
+ # 接受准则
58
+ delta = new_score - current_score
59
+ if delta > 0 or math.exp(delta / temp) > random.random():
60
+ current_solution = new_solution
61
+ current_score = new_score
62
+
63
+ if current_score > best_score:
64
+ best_solution = current_solution.copy()
65
+ best_score = current_score
66
+
67
+ temp *= COOLING_RATE
68
+
69
+ return self._format_result(best_solution, best_score)
70
+
71
+ def _initial_solution(self):
72
+ # 简单的随机分配,尽量填满桌子
73
+ solution = {} # guest_id -> table_id
74
+ table_slots = []
75
+ for t in self.tables:
76
+ for _ in range(t['capacity']):
77
+ table_slots.append(t['id'])
78
+
79
+ random.shuffle(table_slots)
80
+
81
+ for i, guest in enumerate(self.guests):
82
+ if i < len(table_slots):
83
+ solution[guest['id']] = table_slots[i]
84
+ else:
85
+ # 没座位的放在 unassigned (None)
86
+ solution[guest['id']] = None
87
+ return solution
88
+
89
+ def _neighbor_solution(self, solution):
90
+ new_sol = solution.copy()
91
+ guest_ids = list(self.guests)
92
+ if not guest_ids:
93
+ return new_sol
94
+
95
+ action = random.choice(['move', 'swap'])
96
+
97
+ if action == 'move':
98
+ # 移动一个客人到另一张桌子(如果有空位)
99
+ g1 = random.choice(self.guests)['id']
100
+ t_target = random.choice(self.tables)['id']
101
+
102
+ # 检查容量
103
+ current_count = sum(1 for gid, tid in new_sol.items() if tid == t_target)
104
+ table_cap = next(t['capacity'] for t in self.tables if t['id'] == t_target)
105
+
106
+ if current_count < table_cap:
107
+ new_sol[g1] = t_target
108
+
109
+ elif action == 'swap':
110
+ # 交换两个客人的位置
111
+ g1 = random.choice(self.guests)['id']
112
+ g2 = random.choice(self.guests)['id']
113
+ if g1 != g2:
114
+ new_sol[g1], new_sol[g2] = new_sol[g2], new_sol[g1]
115
+
116
+ return new_sol
117
+
118
+ def _calculate_score(self, solution):
119
+ score = 0
120
+
121
+ # 1. 约束分数
122
+ for (g1, g2), weight in self.relation_matrix.items():
123
+ t1 = solution.get(g1)
124
+ t2 = solution.get(g2)
125
+ if t1 is not None and t2 is not None and t1 == t2:
126
+ score += weight * 10 # 权重放大
127
+
128
+ # 2. 组别分数 (同一组的尽量坐一起)
129
+ # 遍历每张桌子
130
+ table_guests = {}
131
+ for gid, tid in solution.items():
132
+ if tid:
133
+ if tid not in table_guests: table_guests[tid] = []
134
+ table_guests[tid].append(self.guest_map[gid])
135
+
136
+ for tid, guests in table_guests.items():
137
+ groups = [g.get('group') for g in guests if g.get('group')]
138
+ # 计算组别一致性
139
+ # 如果桌子上大部分人是同一组,加分
140
+ if groups:
141
+ # 简单做法:每有一对同组人,加 2 分
142
+ for i in range(len(groups)):
143
+ for j in range(i+1, len(groups)):
144
+ if groups[i] == groups[j]:
145
+ score += 2
146
+
147
+ return score
148
+
149
+ def _format_result(self, solution, score):
150
+ # 转换为前端易用的格式
151
+ tables_res = []
152
+ unassigned = []
153
+
154
+ table_guests = {t['id']: [] for t in self.tables}
155
+
156
+ for gid, tid in solution.items():
157
+ if tid:
158
+ table_guests[tid].append(self.guest_map[gid])
159
+ else:
160
+ unassigned.append(self.guest_map[gid])
161
+
162
+ for t in self.tables:
163
+ tables_res.append({
164
+ 'id': t['id'],
165
+ 'name': t['name'],
166
+ 'capacity': t['capacity'],
167
+ 'guests': table_guests[t['id']]
168
+ })
169
+
170
+ return {
171
+ 'tables': tables_res,
172
+ 'unassigned': unassigned,
173
+ 'score': score
174
+ }
175
+
176
+ @app.route('/')
177
+ def index():
178
+ return render_template('index.html')
179
+
180
+ @app.route('/api/solve', methods=['POST'])
181
+ def solve_seating():
182
+ data = request.json
183
+ guests = data.get('guests', [])
184
+ tables = data.get('tables', [])
185
+ constraints = data.get('constraints', []) # [{g1: id, g2: id, type: 'must'|'cannot'}]
186
+
187
+ if not guests or not tables:
188
+ return jsonify({'error': 'Missing guests or tables'}), 400
189
+
190
+ solver = SeatingSolver(guests, tables, constraints)
191
+ result = solver.solve()
192
+
193
+ return jsonify(result)
194
+
195
+ @app.route('/api/upload', methods=['POST'])
196
+ def upload_file():
197
+ if 'file' not in request.files:
198
+ return jsonify({'error': 'No file part'}), 400
199
+ file = request.files['file']
200
+ if file.filename == '':
201
+ return jsonify({'error': 'No selected file'}), 400
202
+
203
+ try:
204
+ # Determine file type
205
+ if file.filename.endswith('.csv'):
206
+ df = pd.read_csv(file)
207
+ elif file.filename.endswith(('.xls', '.xlsx')):
208
+ df = pd.read_excel(file)
209
+ else:
210
+ return jsonify({'error': 'Unsupported file type'}), 400
211
+
212
+ # Normalize columns
213
+ # Expected columns: Name (required), Group (optional), ID (optional)
214
+ # Rename columns to standard names if possible
215
+ df.columns = [str(c).strip().lower() for c in df.columns]
216
+
217
+ # Map common Chinese headers to English keys
218
+ col_map = {
219
+ '姓名': 'name', 'name': 'name',
220
+ '组别': 'group', 'group': 'group', '部门': 'group',
221
+ 'id': 'id', '编号': 'id'
222
+ }
223
+
224
+ # Rename columns
225
+ new_cols = {}
226
+ for col in df.columns:
227
+ if col in col_map:
228
+ new_cols[col] = col_map[col]
229
+ df = df.rename(columns=new_cols)
230
+
231
+ if 'name' not in df.columns:
232
+ return jsonify({'error': '缺少必要列: 姓名 (Name)'}), 400
233
+
234
+ guests = []
235
+ for i, row in df.iterrows():
236
+ guest_id = str(row.get('id', f'g_{i}_{random.randint(1000,9999)}'))
237
+ if pd.isna(guest_id) or guest_id == 'nan':
238
+ guest_id = f'g_{i}_{random.randint(1000,9999)}'
239
+
240
+ name = str(row['name'])
241
+ if pd.isna(name) or name == 'nan':
242
+ continue
243
+
244
+ group = str(row.get('group', 'Default'))
245
+ if pd.isna(group) or group == 'nan':
246
+ group = 'Default'
247
+
248
+ guests.append({
249
+ 'id': guest_id,
250
+ 'name': name,
251
+ 'group': group,
252
+ 'avatar': f'https://api.dicebear.com/7.x/avataaars/svg?seed={guest_id}'
253
+ })
254
+
255
+ return jsonify({'guests': guests})
256
+
257
+ except Exception as e:
258
+ return jsonify({'error': str(e)}), 500
259
+
260
+ @app.route('/api/demo-data')
261
+ def demo_data():
262
+ # 生成演示数据
263
+ groups = ['家人', '同事', '大学同学', '高中同学', '合作伙伴']
264
+ guests = []
265
+ for i in range(50):
266
+ group = random.choice(groups)
267
+ guests.append({
268
+ 'id': f'g_{i}',
269
+ 'name': f'Guest {i+1}',
270
+ 'group': group,
271
+ 'avatar': f'https://api.dicebear.com/7.x/avataaars/svg?seed={i}'
272
+ })
273
+
274
+ tables = []
275
+ for i in range(6):
276
+ tables.append({
277
+ 'id': f't_{i}',
278
+ 'name': f'Table {i+1}',
279
+ 'capacity': 10
280
+ })
281
+
282
+ # 随机生成一些约束
283
+ constraints = []
284
+ # 必须坐一起
285
+ for _ in range(5):
286
+ g1 = random.choice(guests)['id']
287
+ g2 = random.choice(guests)['id']
288
+ if g1 != g2:
289
+ constraints.append({'g1': g1, 'g2': g2, 'type': 'must', 'weight': 10})
290
+
291
+ # 不能坐一起
292
+ for _ in range(3):
293
+ g1 = random.choice(guests)['id']
294
+ g2 = random.choice(guests)['id']
295
+ if g1 != g2:
296
+ constraints.append({'g1': g1, 'g2': g2, 'type': 'cannot', 'weight': 10})
297
+
298
+ return jsonify({
299
+ 'guests': guests,
300
+ 'tables': tables,
301
+ 'constraints': constraints
302
+ })
303
+
304
+ if __name__ == '__main__':
305
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ numpy
3
+ gunicorn
4
+ pandas
5
+ openpyxl
templates/index.html ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Seating Chart Solver | 智能排座专家</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" />
9
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
10
+ <script src="//unpkg.com/element-plus"></script>
11
+ <script src="//unpkg.com/@element-plus/icons-vue"></script>
12
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
14
+ <style>
15
+ body { background-color: #f3f4f6; }
16
+ .guest-item { cursor: grab; transition: all 0.2s; }
17
+ .guest-item:active { cursor: grabbing; }
18
+ .table-circle { transition: all 0.3s; }
19
+ .table-circle:hover { transform: scale(1.02); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); }
20
+ .chair { position: absolute; width: 40px; height: 40px; border-radius: 50%; transform-origin: center 100px; }
21
+ .chair-content { width: 100%; height: 100%; border-radius: 50%; background: white; border: 2px solid #e5e7eb; display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 10px; position: relative; }
22
+ .chair-content img { width: 100%; height: 100%; object-fit: cover; }
23
+ .chair-content.empty { background: #f9fafb; border-style: dashed; }
24
+ [v-cloak] { display: none; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <div id="app" v-cloak class="flex h-screen overflow-hidden">
29
+ <!-- Sidebar: Configuration -->
30
+ <div class="w-1/4 bg-white shadow-lg z-10 flex flex-col border-r">
31
+ <div class="p-5 border-b bg-gray-50">
32
+ <h1 class="text-xl font-bold flex items-center text-gray-800">
33
+ <el-icon class="mr-2 text-indigo-600"><Grid /></el-icon>
34
+ 智能排座专家
35
+ </h1>
36
+ <p class="text-xs text-gray-500 mt-1">基于模拟退火算法 (Simulated Annealing)</p>
37
+ </div>
38
+
39
+ <div class="flex-1 overflow-y-auto p-5 space-y-6">
40
+ <!-- Data Controls -->
41
+ <div class="space-y-3">
42
+ <div class="flex justify-between items-center">
43
+ <h3 class="font-bold text-gray-700">宾客名单 (${guests.length})</h3>
44
+ <div class="space-x-2">
45
+ <input type="file" ref="fileInput" class="hidden" accept=".xlsx,.xls,.csv" @change="handleFileUpload">
46
+ <el-button size="small" @click="triggerUpload">导入Excel</el-button>
47
+ <el-button size="small" @click="loadDemoData" :loading="loading">演示数据</el-button>
48
+ </div>
49
+ </div>
50
+ <div class="max-h-40 overflow-y-auto border rounded p-2 bg-gray-50 text-sm">
51
+ <div v-for="g in guests" :key="g.id" class="flex items-center justify-between py-1 border-b last:border-0">
52
+ <div class="flex items-center">
53
+ <span class="w-2 h-2 rounded-full mr-2" :style="{background: getGroupColor(g.group)}"></span>
54
+ ${g.name}
55
+ </div>
56
+ <span class="text-xs text-gray-400">${g.group}</span>
57
+ </div>
58
+ <div v-if="guests.length === 0" class="text-center text-gray-400 py-4">暂无数据</div>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="space-y-3">
63
+ <h3 class="font-bold text-gray-700">桌台设置 (${tables.length})</h3>
64
+ <div class="max-h-40 overflow-y-auto border rounded p-2 bg-gray-50 text-sm">
65
+ <div v-for="t in tables" :key="t.id" class="flex justify-between py-1 border-b last:border-0">
66
+ <span>${t.name}</span>
67
+ <span class="text-gray-500">${t.capacity}座</span>
68
+ </div>
69
+ <div v-if="tables.length === 0" class="text-center text-gray-400 py-4">暂无数据</div>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="space-y-3">
74
+ <h3 class="font-bold text-gray-700">排座约束 (${constraints.length})</h3>
75
+ <div class="max-h-32 overflow-y-auto border rounded p-2 bg-gray-50 text-sm">
76
+ <div v-for="(c, i) in constraints" :key="i" class="flex items-center text-xs py-1">
77
+ <el-tag size="small" :type="c.type === 'must' ? 'success' : 'danger'" class="mr-2">
78
+ ${c.type === 'must' ? '必须' : '不能'}
79
+ </el-tag>
80
+ ${getGuestName(c.g1)} & ${getGuestName(c.g2)}
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="p-5 border-t bg-gray-50">
87
+ <el-button type="primary" class="w-full" size="large" @click="solveSeating" :loading="solving" :disabled="guests.length === 0">
88
+ <el-icon class="mr-2"><Magic-Stick /></el-icon> 开始智能排座
89
+ </el-button>
90
+ <div class="mt-2 text-center text-xs text-gray-400" v-if="lastScore">
91
+ 当前方案评分: <span class="font-bold text-indigo-600">${lastScore}</span>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Main: Visualization -->
97
+ <div class="flex-1 flex flex-col h-full bg-gray-100">
98
+ <!-- Toolbar -->
99
+ <div class="h-14 bg-white border-b flex items-center justify-between px-6 shadow-sm">
100
+ <div class="flex items-center space-x-4">
101
+ <div class="flex items-center text-sm">
102
+ <span class="w-3 h-3 rounded-full bg-indigo-500 mr-2"></span>
103
+ <span>已分配: ${assignedCount}</span>
104
+ </div>
105
+ <div class="flex items-center text-sm">
106
+ <span class="w-3 h-3 rounded-full bg-gray-300 mr-2"></span>
107
+ <span>未分配: ${unassignedGuests.length}</span>
108
+ </div>
109
+ </div>
110
+ <div class="flex space-x-2">
111
+ <el-button size="default" @click="exportImage" icon="Picture">导出图片</el-button>
112
+ <el-button size="default" @click="exportPDF" type="danger" icon="Document">导出 PDF</el-button>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Canvas -->
117
+ <div class="flex-1 overflow-auto p-8 relative" id="seating-canvas">
118
+ <div v-if="tables.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
119
+ <el-icon size="48" class="mb-4"><Files /></el-icon>
120
+ <p>请先加载数据或创建桌台</p>
121
+ </div>
122
+
123
+ <div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-12 auto-rows-max">
124
+ <div v-for="table in tables" :key="table.id"
125
+ class="relative w-64 h-64 mx-auto table-circle bg-white rounded-full shadow-md border-4 border-indigo-100 flex items-center justify-center">
126
+
127
+ <div class="text-center z-10">
128
+ <div class="font-bold text-gray-700 text-lg">${table.name}</div>
129
+ <div class="text-xs text-gray-400">${getAssignedGuests(table.id).length}/${table.capacity}</div>
130
+ </div>
131
+
132
+ <!-- Chairs -->
133
+ <div v-for="(seat, idx) in getTableSeats(table)" :key="idx"
134
+ class="absolute w-10 h-10"
135
+ :style="getSeatStyle(idx, table.capacity)">
136
+
137
+ <div class="chair-content shadow-sm" :class="{'empty': !seat}" :title="seat ? seat.name : '空座'">
138
+ <img v-if="seat && seat.avatar" :src="seat.avatar" />
139
+ <span v-else-if="seat" class="font-bold text-gray-600">${seat.name[0]}</span>
140
+ <span v-else class="text-gray-300 text-xs">${idx+1}</span>
141
+ </div>
142
+
143
+ <div v-if="seat" class="absolute -bottom-5 left-1/2 transform -translate-x-1/2 whitespace-nowrap text-[10px] bg-gray-800 text-white px-1 rounded opacity-80 z-20">
144
+ ${seat.name}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+
153
+ <script>
154
+ const { createApp, ref, computed } = Vue;
155
+
156
+ const app = createApp({
157
+ delimiters: ['${', '}'],
158
+ setup() {
159
+ const guests = ref([]);
160
+ const tables = ref([]);
161
+ const constraints = ref([]);
162
+ const solution = ref({}); // guest_id -> table_id
163
+ const fileInput = ref(null);
164
+
165
+ const loading = ref(false);
166
+ const solving = ref(false);
167
+ const lastScore = ref(null);
168
+
169
+ const groupColors = {};
170
+
171
+ const getGroupColor = (group) => {
172
+ if (!groupColors[group]) {
173
+ const colors = ['#f87171', '#fb923c', '#fbbf24', '#a3e635', '#34d399', '#22d3ee', '#818cf8', '#e879f9'];
174
+ groupColors[group] = colors[Object.keys(groupColors).length % colors.length];
175
+ }
176
+ return groupColors[group];
177
+ };
178
+
179
+ const loadDemoData = async () => {
180
+ loading.value = true;
181
+ try {
182
+ const res = await fetch('/api/demo-data');
183
+ const data = await res.json();
184
+ guests.value = data.guests;
185
+ tables.value = data.tables;
186
+ constraints.value = data.constraints;
187
+ solution.value = {}; // Reset
188
+ lastScore.value = null;
189
+ ElementPlus.ElMessage.success('演示数据加载成功');
190
+ } catch (e) {
191
+ ElementPlus.ElMessage.error('加载失败');
192
+ } finally {
193
+ loading.value = false;
194
+ }
195
+ };
196
+
197
+ const triggerUpload = () => {
198
+ fileInput.value.click();
199
+ };
200
+
201
+ const handleFileUpload = async (event) => {
202
+ const file = event.target.files[0];
203
+ if (!file) return;
204
+
205
+ const formData = new FormData();
206
+ formData.append('file', file);
207
+
208
+ loading.value = true;
209
+ try {
210
+ const res = await fetch('/api/upload', {
211
+ method: 'POST',
212
+ body: formData
213
+ });
214
+ const data = await res.json();
215
+
216
+ if (res.ok) {
217
+ guests.value = data.guests;
218
+ ElementPlus.ElMessage.success(`成功导入 ${data.guests.length} 位宾客`);
219
+ // Clear input value to allow re-uploading same file
220
+ event.target.value = '';
221
+ } else {
222
+ ElementPlus.ElMessage.error(data.error || '上传失败');
223
+ }
224
+ } catch (e) {
225
+ ElementPlus.ElMessage.error('上传出错: ' + e.message);
226
+ } finally {
227
+ loading.value = false;
228
+ }
229
+ };
230
+
231
+ // Auto load demo data if empty on init
232
+ Vue.onMounted(() => {
233
+ loadDemoData();
234
+ });
235
+
236
+ const solveSeating = async () => {
237
+ solving.value = true;
238
+ try {
239
+ const res = await fetch('/api/solve', {
240
+ method: 'POST',
241
+ headers: {'Content-Type': 'application/json'},
242
+ body: JSON.stringify({
243
+ guests: guests.value,
244
+ tables: tables.value,
245
+ constraints: constraints.value
246
+ })
247
+ });
248
+ const result = await res.json();
249
+
250
+ // Parse result back to solution map for reactivity
251
+ const newSol = {};
252
+ result.tables.forEach(t => {
253
+ t.guests.forEach(g => {
254
+ newSol[g.id] = t.id;
255
+ });
256
+ });
257
+ solution.value = newSol;
258
+ lastScore.value = result.score;
259
+ ElementPlus.ElMessage.success(`优化完成! 评分: ${result.score}`);
260
+ } catch (e) {
261
+ ElementPlus.ElMessage.error('计算失败');
262
+ } finally {
263
+ solving.value = false;
264
+ }
265
+ };
266
+
267
+ const getGuestName = (id) => {
268
+ const g = guests.value.find(x => x.id === id);
269
+ return g ? g.name : id;
270
+ };
271
+
272
+ const getAssignedGuests = (tableId) => {
273
+ return guests.value.filter(g => solution.value[g.id] === tableId);
274
+ };
275
+
276
+ const unassignedGuests = computed(() => {
277
+ return guests.value.filter(g => !solution.value[g.id]);
278
+ });
279
+
280
+ const assignedCount = computed(() => {
281
+ return guests.value.filter(g => solution.value[g.id]).length;
282
+ });
283
+
284
+ const getTableSeats = (table) => {
285
+ const assigned = getAssignedGuests(table.id);
286
+ const seats = new Array(table.capacity).fill(null);
287
+ assigned.forEach((g, i) => {
288
+ if (i < table.capacity) seats[i] = g;
289
+ });
290
+ return seats;
291
+ };
292
+
293
+ const getSeatStyle = (index, capacity) => {
294
+ // Calculate position on circle
295
+ const angle = (index * 360 / capacity) * (Math.PI / 180);
296
+ // Radius = 100px (half of w-64/256px minus padding)
297
+ const r = 90;
298
+ const x = Math.sin(angle) * r; // x offset
299
+ const y = -Math.cos(angle) * r; // y offset
300
+
301
+ return {
302
+ left: '50%',
303
+ top: '50%',
304
+ transform: `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`
305
+ };
306
+ };
307
+
308
+ const exportImage = () => {
309
+ const element = document.getElementById('seating-canvas');
310
+ html2canvas(element).then(canvas => {
311
+ const link = document.createElement('a');
312
+ link.download = 'seating-chart.png';
313
+ link.href = canvas.toDataURL();
314
+ link.click();
315
+ });
316
+ };
317
+
318
+ const exportPDF = () => {
319
+ const { jsPDF } = window.jspdf;
320
+ const element = document.getElementById('seating-canvas');
321
+ html2canvas(element).then(canvas => {
322
+ const imgData = canvas.toDataURL('image/png');
323
+ const pdf = new jsPDF('l', 'mm', 'a4');
324
+ const width = pdf.internal.pageSize.getWidth();
325
+ const height = pdf.internal.pageSize.getHeight();
326
+ pdf.addImage(imgData, 'PNG', 0, 0, width, height);
327
+ pdf.save("seating-plan.pdf");
328
+ });
329
+ };
330
+
331
+ return {
332
+ guests, tables, constraints,
333
+ loading, solving, lastScore,
334
+ unassignedGuests, assignedCount,
335
+ loadDemoData, solveSeating,
336
+ getGroupColor, getGuestName,
337
+ getAssignedGuests, getTableSeats, getSeatStyle,
338
+ exportImage, exportPDF,
339
+ fileInput, triggerUpload, handleFileUpload
340
+ };
341
+ }
342
+ });
343
+
344
+ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
345
+ app.component(key, component)
346
+ }
347
+ app.use(ElementPlus).mount('#app');
348
+ </script>
349
+ </body>
350
+ </html>