Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Seating Chart Solver | 智能排座专家</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" /> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="//unpkg.com/element-plus"></script> | |
| <script src="//unpkg.com/@element-plus/icons-vue"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <style> | |
| body { background-color: #f3f4f6; } | |
| .guest-item { cursor: grab; transition: all 0.2s; } | |
| .guest-item:active { cursor: grabbing; } | |
| .table-circle { transition: all 0.3s; } | |
| .table-circle:hover { transform: scale(1.02); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); } | |
| .chair { position: absolute; width: 40px; height: 40px; border-radius: 50%; transform-origin: center 100px; } | |
| .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; } | |
| .chair-content img { width: 100%; height: 100%; object-fit: cover; } | |
| .chair-content.empty { background: #f9fafb; border-style: dashed; } | |
| [v-cloak] { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" v-cloak class="flex h-screen overflow-hidden"> | |
| <!-- Sidebar: Configuration --> | |
| <div class="w-1/4 bg-white shadow-lg z-10 flex flex-col border-r"> | |
| <div class="p-5 border-b bg-gray-50"> | |
| <h1 class="text-xl font-bold flex items-center text-gray-800"> | |
| <el-icon class="mr-2 text-indigo-600"><Grid /></el-icon> | |
| 智能排座专家 | |
| </h1> | |
| <p class="text-xs text-gray-500 mt-1">基于模拟退火算法 (Simulated Annealing)</p> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-5 space-y-6"> | |
| <!-- Data Controls --> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between items-center"> | |
| <h3 class="font-bold text-gray-700">宾客名单 (${guests.length})</h3> | |
| <div class="space-x-2"> | |
| <input type="file" ref="fileInput" class="hidden" accept=".xlsx,.xls,.csv" @change="handleFileUpload"> | |
| <el-button size="small" @click="triggerUpload">导入Excel</el-button> | |
| <el-button size="small" @click="loadDemoData" :loading="loading">演示数据</el-button> | |
| </div> | |
| </div> | |
| <div class="max-h-40 overflow-y-auto border rounded p-2 bg-gray-50 text-sm"> | |
| <div v-for="g in guests" :key="g.id" class="flex items-center justify-between py-1 border-b last:border-0"> | |
| <div class="flex items-center"> | |
| <span class="w-2 h-2 rounded-full mr-2" :style="{background: getGroupColor(g.group)}"></span> | |
| ${g.name} | |
| </div> | |
| <span class="text-xs text-gray-400">${g.group}</span> | |
| </div> | |
| <div v-if="guests.length === 0" class="text-center text-gray-400 py-4">暂无数据</div> | |
| </div> | |
| </div> | |
| <div class="space-y-3"> | |
| <h3 class="font-bold text-gray-700">桌台设置 (${tables.length})</h3> | |
| <div class="max-h-40 overflow-y-auto border rounded p-2 bg-gray-50 text-sm"> | |
| <div v-for="t in tables" :key="t.id" class="flex justify-between py-1 border-b last:border-0"> | |
| <span>${t.name}</span> | |
| <span class="text-gray-500">${t.capacity}座</span> | |
| </div> | |
| <div v-if="tables.length === 0" class="text-center text-gray-400 py-4">暂无数据</div> | |
| </div> | |
| </div> | |
| <div class="space-y-3"> | |
| <h3 class="font-bold text-gray-700">排座约束 (${constraints.length})</h3> | |
| <div class="max-h-32 overflow-y-auto border rounded p-2 bg-gray-50 text-sm"> | |
| <div v-for="(c, i) in constraints" :key="i" class="flex items-center text-xs py-1"> | |
| <el-tag size="small" :type="c.type === 'must' ? 'success' : 'danger'" class="mr-2"> | |
| ${c.type === 'must' ? '必须' : '不能'} | |
| </el-tag> | |
| ${getGuestName(c.g1)} & ${getGuestName(c.g2)} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-5 border-t bg-gray-50"> | |
| <el-button type="primary" class="w-full" size="large" @click="solveSeating" :loading="solving" :disabled="guests.length === 0"> | |
| <el-icon class="mr-2"><Magic-Stick /></el-icon> 开始智能排座 | |
| </el-button> | |
| <div class="mt-2 text-center text-xs text-gray-400" v-if="lastScore"> | |
| 当前方案评分: <span class="font-bold text-indigo-600">${lastScore}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main: Visualization --> | |
| <div class="flex-1 flex flex-col h-full bg-gray-100"> | |
| <!-- Toolbar --> | |
| <div class="h-14 bg-white border-b flex items-center justify-between px-6 shadow-sm"> | |
| <div class="flex items-center space-x-4"> | |
| <div class="flex items-center text-sm"> | |
| <span class="w-3 h-3 rounded-full bg-indigo-500 mr-2"></span> | |
| <span>已分配: ${assignedCount}</span> | |
| </div> | |
| <div class="flex items-center text-sm"> | |
| <span class="w-3 h-3 rounded-full bg-gray-300 mr-2"></span> | |
| <span>未分配: ${unassignedGuests.length}</span> | |
| </div> | |
| </div> | |
| <div class="flex space-x-2"> | |
| <el-button size="default" @click="exportImage" icon="Picture">导出图片</el-button> | |
| <el-button size="default" @click="exportPDF" type="danger" icon="Document">导出 PDF</el-button> | |
| </div> | |
| </div> | |
| <!-- Canvas --> | |
| <div class="flex-1 overflow-auto p-8 relative" id="seating-canvas"> | |
| <div v-if="tables.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400"> | |
| <el-icon size="48" class="mb-4"><Files /></el-icon> | |
| <p>请先加载数据或创建桌台</p> | |
| </div> | |
| <div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-12 auto-rows-max"> | |
| <div v-for="table in tables" :key="table.id" | |
| 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"> | |
| <div class="text-center z-10"> | |
| <div class="font-bold text-gray-700 text-lg">${table.name}</div> | |
| <div class="text-xs text-gray-400">${getAssignedGuests(table.id).length}/${table.capacity}</div> | |
| </div> | |
| <!-- Chairs --> | |
| <div v-for="(seat, idx) in getTableSeats(table)" :key="idx" | |
| class="absolute w-10 h-10" | |
| :style="getSeatStyle(idx, table.capacity)"> | |
| <div class="chair-content shadow-sm" :class="{'empty': !seat}" :title="seat ? seat.name : '空座'"> | |
| <img v-if="seat && seat.avatar" :src="seat.avatar" /> | |
| <span v-else-if="seat" class="font-bold text-gray-600">${seat.name[0]}</span> | |
| <span v-else class="text-gray-300 text-xs">${idx+1}</span> | |
| </div> | |
| <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"> | |
| ${seat.name} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, computed } = Vue; | |
| const app = createApp({ | |
| delimiters: ['${', '}'], | |
| setup() { | |
| const guests = ref([]); | |
| const tables = ref([]); | |
| const constraints = ref([]); | |
| const solution = ref({}); // guest_id -> table_id | |
| const fileInput = ref(null); | |
| const loading = ref(false); | |
| const solving = ref(false); | |
| const lastScore = ref(null); | |
| const groupColors = {}; | |
| const getGroupColor = (group) => { | |
| if (!groupColors[group]) { | |
| const colors = ['#f87171', '#fb923c', '#fbbf24', '#a3e635', '#34d399', '#22d3ee', '#818cf8', '#e879f9']; | |
| groupColors[group] = colors[Object.keys(groupColors).length % colors.length]; | |
| } | |
| return groupColors[group]; | |
| }; | |
| const loadDemoData = async () => { | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/demo-data'); | |
| const data = await res.json(); | |
| guests.value = data.guests; | |
| tables.value = data.tables; | |
| constraints.value = data.constraints; | |
| solution.value = {}; // Reset | |
| lastScore.value = null; | |
| ElementPlus.ElMessage.success('演示数据加载成功'); | |
| } catch (e) { | |
| ElementPlus.ElMessage.error('加载失败'); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const triggerUpload = () => { | |
| fileInput.value.click(); | |
| }; | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| guests.value = data.guests; | |
| ElementPlus.ElMessage.success(`成功导入 ${data.guests.length} 位宾客`); | |
| // Clear input value to allow re-uploading same file | |
| event.target.value = ''; | |
| } else { | |
| ElementPlus.ElMessage.error(data.error || '上传失败'); | |
| } | |
| } catch (e) { | |
| ElementPlus.ElMessage.error('上传出错: ' + e.message); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| // Auto load demo data if empty on init | |
| Vue.onMounted(() => { | |
| loadDemoData(); | |
| }); | |
| const solveSeating = async () => { | |
| solving.value = true; | |
| try { | |
| const res = await fetch('/api/solve', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| guests: guests.value, | |
| tables: tables.value, | |
| constraints: constraints.value | |
| }) | |
| }); | |
| const result = await res.json(); | |
| // Parse result back to solution map for reactivity | |
| const newSol = {}; | |
| result.tables.forEach(t => { | |
| t.guests.forEach(g => { | |
| newSol[g.id] = t.id; | |
| }); | |
| }); | |
| solution.value = newSol; | |
| lastScore.value = result.score; | |
| ElementPlus.ElMessage.success(`优化完成! 评分: ${result.score}`); | |
| } catch (e) { | |
| ElementPlus.ElMessage.error('计算失败'); | |
| } finally { | |
| solving.value = false; | |
| } | |
| }; | |
| const getGuestName = (id) => { | |
| const g = guests.value.find(x => x.id === id); | |
| return g ? g.name : id; | |
| }; | |
| const getAssignedGuests = (tableId) => { | |
| return guests.value.filter(g => solution.value[g.id] === tableId); | |
| }; | |
| const unassignedGuests = computed(() => { | |
| return guests.value.filter(g => !solution.value[g.id]); | |
| }); | |
| const assignedCount = computed(() => { | |
| return guests.value.filter(g => solution.value[g.id]).length; | |
| }); | |
| const getTableSeats = (table) => { | |
| const assigned = getAssignedGuests(table.id); | |
| const seats = new Array(table.capacity).fill(null); | |
| assigned.forEach((g, i) => { | |
| if (i < table.capacity) seats[i] = g; | |
| }); | |
| return seats; | |
| }; | |
| const getSeatStyle = (index, capacity) => { | |
| // Calculate position on circle | |
| const angle = (index * 360 / capacity) * (Math.PI / 180); | |
| // Radius = 100px (half of w-64/256px minus padding) | |
| const r = 90; | |
| const x = Math.sin(angle) * r; // x offset | |
| const y = -Math.cos(angle) * r; // y offset | |
| return { | |
| left: '50%', | |
| top: '50%', | |
| transform: `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))` | |
| }; | |
| }; | |
| const exportImage = () => { | |
| const element = document.getElementById('seating-canvas'); | |
| html2canvas(element).then(canvas => { | |
| const link = document.createElement('a'); | |
| link.download = 'seating-chart.png'; | |
| link.href = canvas.toDataURL(); | |
| link.click(); | |
| }); | |
| }; | |
| const exportPDF = () => { | |
| const { jsPDF } = window.jspdf; | |
| const element = document.getElementById('seating-canvas'); | |
| html2canvas(element).then(canvas => { | |
| const imgData = canvas.toDataURL('image/png'); | |
| const pdf = new jsPDF('l', 'mm', 'a4'); | |
| const width = pdf.internal.pageSize.getWidth(); | |
| const height = pdf.internal.pageSize.getHeight(); | |
| pdf.addImage(imgData, 'PNG', 0, 0, width, height); | |
| pdf.save("seating-plan.pdf"); | |
| }); | |
| }; | |
| return { | |
| guests, tables, constraints, | |
| loading, solving, lastScore, | |
| unassignedGuests, assignedCount, | |
| loadDemoData, solveSeating, | |
| getGroupColor, getGuestName, | |
| getAssignedGuests, getTableSeats, getSeatStyle, | |
| exportImage, exportPDF, | |
| fileInput, triggerUpload, handleFileUpload | |
| }; | |
| } | |
| }); | |
| for (const [key, component] of Object.entries(ElementPlusIconsVue)) { | |
| app.component(key, component) | |
| } | |
| app.use(ElementPlus).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |