Spaces:
Sleeping
Sleeping
Trae Assistant commited on
Commit ·
b185c87
0
Parent(s):
Initial commit with enhanced functionality
Browse files- .gitignore +5 -0
- Dockerfile +15 -0
- README.md +55 -0
- app.py +305 -0
- requirements.txt +5 -0
- 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>
|