Trae Assistant
Initial commit with enhanced functionality
b185c87
<!DOCTYPE html>
<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>