seat / index.html
Lashtw's picture
Update index.html
c829d91 verified
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>互動式座位表產生器 V2</title>
<!-- 引入 Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 引入 FontAwesome 圖標 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* --- 列印專用樣式 --- */
@media print {
@page {
size: A4 landscape;
margin: 5mm;
}
body {
background: white;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.no-print {
display: none !important;
}
.print-area {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border: none !important;
box-shadow: none !important;
overflow: visible !important;
}
/* 隱藏空位 */
.seat-empty {
visibility: hidden !important;
border: none !important;
}
/* 輸入框樣式重置 */
input {
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
input::placeholder {
color: transparent;
}
/* 隱藏操作圖示 */
.seat-actions {
display: none !important;
}
}
/* --- UI 樣式 --- */
/* 拖曳時的原始卡片樣式 */
.dragging-source {
opacity: 0.3;
background-color: #e5e7eb;
}
/* 觸控拖曳時的 Ghost 元素 (跟隨手指) */
.touch-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.9;
transform: scale(1.05) rotate(2deg);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
width: 120px; /* 約略寬度 */
background: white;
border: 2px solid #2563eb;
border-radius: 0.75rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 座位卡片基礎樣式 */
.seat-card {
user-select: none;
/* 關鍵:防止手機上觸控卡片時觸發瀏覽器捲動,讓 JS 接管 Touch 事件 */
touch-action: none;
position: relative;
}
/* 鎖定狀態 */
.seat-locked {
background-color: #f3f4f6 !important; /* gray-100 */
border-color: #9ca3af !important; /* gray-400 */
cursor: not-allowed !important;
}
.seat-locked input {
color: #6b7280 !important; /* gray-500 */
pointer-events: none;
}
/* Modal 動畫 */
.modal-enter {
opacity: 0;
transform: scale(0.95);
}
.modal-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 200ms, transform 200ms;
}
/* 自定義字體大小類別 */
.text-size-xl { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
.text-size-2xl { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
.text-size-3xl { font-size: 1.875rem; line-height: 2.25rem; font-weight: 700; }
.text-size-4xl { font-size: 2.25rem; line-height: 2.5rem; font-weight: 800; }
.text-size-sm { font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; }
</style>
</head>
<body class="bg-gray-100 font-sans text-gray-800 min-h-screen flex flex-col">
<!-- 頂部控制列 -->
<header class="bg-white border-b border-gray-200 p-3 shadow-sm no-print sticky top-0 z-40">
<div class="max-w-7xl mx-auto flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<div class="bg-blue-600 p-2 rounded-lg text-white">
<i class="fa-solid fa-chair text-lg"></i>
</div>
<h1 class="text-lg font-bold text-gray-800 hidden sm:block">座位表產生器 V2</h1>
</div>
<!-- 格子設定 -->
<div class="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-200">
<input type="number" id="input-rows" min="1" max="12" value="4" class="w-10 p-1 text-center border rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm" title="行 (Y)">
<span class="text-gray-400 text-xs">×</span>
<input type="number" id="input-cols" min="1" max="12" value="6" class="w-10 p-1 text-center border rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm" title="列 (X)">
</div>
<!-- 功能按鈕區 -->
<div class="flex items-center gap-2 flex-wrap">
<button onclick="openImportModal()" class="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded shadow transition-colors text-sm font-medium">
<i class="fa-solid fa-file-excel"></i> <span class="hidden sm:inline">批量匯入</span>
</button>
<div class="h-5 w-px bg-gray-300 mx-1 hidden sm:block"></div>
<button onclick="clearAll()" class="px-3 py-1.5 text-red-600 bg-red-50 hover:bg-red-100 rounded border border-red-200 transition-colors text-sm" title="清空所有">
<i class="fa-solid fa-trash-can"></i>
</button>
<button onclick="saveToFile()" class="px-3 py-1.5 text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded transition-colors text-sm" title="下載存檔 (JSON)">
<i class="fa-solid fa-download"></i>
</button>
<button onclick="window.print()" class="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded shadow transition-all font-medium active:scale-95 text-sm">
<i class="fa-solid fa-print"></i> <span>匯出 PDF</span>
</button>
</div>
</div>
</header>
<!-- 主內容區 -->
<main class="flex-1 p-4 sm:p-8 overflow-auto flex justify-center items-start print-area bg-gray-100/50">
<!-- A4 紙張容器 (Landscape) -->
<div id="paper-container" class="bg-white shadow-2xl p-8 w-full max-w-[297mm] min-h-[210mm] flex flex-col items-center border border-gray-200 rounded-sm relative print:shadow-none print:border-none print:p-0 transition-all duration-300">
<!-- 儲存狀態提示 -->
<div id="save-status" class="absolute top-2 right-2 text-xs text-green-600 opacity-0 transition-opacity duration-500 no-print">
<i class="fa-solid fa-check-circle"></i> 已自動儲存
</div>
<!-- 教室標題 -->
<div class="w-full mb-6 text-center group">
<input type="text" id="classroom-title" value="班級座位表"
class="text-3xl font-bold text-center w-full border-b-2 border-transparent hover:border-gray-300 focus:border-blue-500 outline-none bg-transparent transition-colors print:border-none p-2 placeholder-gray-300">
</div>
<!-- 座位網格容器 -->
<div id="seat-grid" class="grid gap-4 w-full mb-12 flex-1" style="grid-template-columns: repeat(6, 1fr);">
<!-- 座位將由 JavaScript 動態生成 -->
</div>
<!-- 講台 / 黑板 -->
<div class="w-full mt-auto mb-2 text-center">
<div class="w-2/3 mx-auto h-12 bg-amber-50 border-2 border-amber-200 rounded-lg flex items-center justify-center shadow-sm print:border-amber-900 print:bg-transparent print:border-2">
<span class="text-amber-800 font-bold tracking-[0.5em] text-base print:text-black">講 台 / 黑 板</span>
</div>
</div>
<!-- 頁尾資訊 -->
<div class="w-full hidden print:flex justify-between text-xs text-gray-500 mt-4 border-t border-gray-200 pt-2">
<span>導師簽名:________________</span>
<span id="print-date"></span>
</div>
</div>
</main>
<!-- 底部提示 -->
<div class="bg-white border-t p-2 text-center text-xs text-gray-500 no-print">
<span class="hidden sm:inline">提示:</span>
<span class="mr-2"><i class="fa-solid fa-lock"></i> 可鎖定座位</span>
<span class="mr-2"><i class="fa-regular fa-hand-pointer"></i> 支援觸控拖曳</span>
<span><i class="fa-solid fa-floppy-disk"></i> 自動存檔中</span>
</div>
<!-- 批量匯入 Modal (已更新) -->
<div id="import-modal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh] modal-enter">
<div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50 rounded-t-xl">
<h3 class="font-bold text-lg text-gray-800"><i class="fa-solid fa-paste text-green-600 mr-2"></i>批量匯入名單</h3>
<button onclick="closeImportModal()" class="text-gray-400 hover:text-gray-600 w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-200 transition-colors">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="p-6 flex-1 overflow-y-auto">
<p class="text-sm text-gray-600 mb-2">請貼上學生名單(支援 Excel 直接複製):</p>
<div class="text-xs text-gray-500 mb-3 bg-blue-50 p-3 rounded border border-blue-100 leading-relaxed">
<i class="fa-solid fa-circle-info mr-1 text-blue-600"></i> <b>格式說明:</b><br>
每行輸入一位學生。支援「座號+姓名」或「僅姓名」。<br>
例如:<br>
<code class="bg-white px-1 rounded border">1 王小明</code> (推薦)<br>
<code class="bg-white px-1 rounded border">2.李大同</code><br>
<code class="bg-white px-1 rounded border">陳雅婷</code>
</div>
<textarea id="import-textarea" class="w-full h-48 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm resize-none" placeholder="1 王小明&#10;2 李大同&#10;03 陳雅婷&#10;張偉..."></textarea>
<div class="mt-4 flex items-center gap-2">
<input type="checkbox" id="overwrite-check" class="rounded text-blue-600 focus:ring-blue-500">
<label for="overwrite-check" class="text-sm text-gray-700 cursor-pointer">強制覆蓋現有內容 (包含已填寫的格子)</label>
</div>
</div>
<div class="p-4 border-t border-gray-100 flex justify-end gap-3 bg-gray-50 rounded-b-xl">
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg transition-colors font-medium">取消</button>
<button onclick="processImport()" class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg shadow transition-colors font-medium">
<i class="fa-solid fa-check mr-1"></i> 開始匯入
</button>
</div>
</div>
</div>
<!-- JavaScript 邏輯 -->
<script>
// --- 核心狀態 ---
let state = {
rows: 4,
cols: 6,
title: "班級座位表",
seats: [] // { id, name, number, isEmpty, locked }
};
// DOM 緩存
const els = {
grid: document.getElementById('seat-grid'),
inputRows: document.getElementById('input-rows'),
inputCols: document.getElementById('input-cols'),
title: document.getElementById('classroom-title'),
date: document.getElementById('print-date'),
modal: document.getElementById('import-modal'),
importText: document.getElementById('import-textarea'),
overwrite: document.getElementById('overwrite-check'),
saveStatus: document.getElementById('save-status')
};
const STORAGE_KEY = 'seating-chart-autosave-v2';
// --- 初始化與生命週期 ---
function init() {
els.date.textContent = `製表日期:${new Date().toLocaleDateString()}`;
// 嘗試讀取 Auto-save
if (!loadFromLocalStorage()) {
initializeSeats(state.rows, state.cols);
}
// 綁定基礎事件
els.inputRows.addEventListener('change', updateGridDimensions);
els.inputCols.addEventListener('change', updateGridDimensions);
els.title.addEventListener('input', (e) => {
state.title = e.target.value;
saveState();
});
// 視窗關閉前警告 (防呆)
window.addEventListener('beforeunload', (e) => {
// 這裡其實不需要做太多,因為我們有 auto-save,但為了保險起見
});
renderGrid();
}
// 初始化資料結構
function initializeSeats(rows, cols) {
const total = rows * cols;
state.seats = Array.from({ length: total }, (_, i) => ({
id: i,
name: '',
number: '',
isEmpty: true,
locked: false
}));
state.rows = rows;
state.cols = cols;
saveState(); // 初始存檔
}
// --- 核心邏輯 ---
// 1. Grid 調整
function updateGridDimensions() {
const newRows = parseInt(els.inputRows.value) || 1;
const newCols = parseInt(els.inputCols.value) || 1;
const total = newRows * newCols;
// 重建陣列但保留舊資料
const newSeats = [];
for (let i = 0; i < total; i++) {
if (i < state.seats.length) {
newSeats.push({ ...state.seats[i] });
} else {
newSeats.push({ id: i, name: '', number: '', isEmpty: true, locked: false });
}
}
state.rows = newRows;
state.cols = newCols;
state.seats = newSeats;
saveState();
renderGrid();
}
// 2. 單一座位更新
function updateSeatData(index, field, value) {
const seat = state.seats[index];
if (seat.locked) return; // 鎖定防呆
seat[field] = value;
seat.isEmpty = (seat.name.trim() === '' && seat.number.trim() === '');
// 局部 DOM 更新 (優化效能)
const card = document.getElementById(`seat-${index}`);
const input = document.getElementById(`input-${field}-${index}`);
const nameInput = document.getElementById(`input-name-${index}`);
if (seat.isEmpty) {
card.classList.add('seat-empty', 'border-dashed', 'bg-white/50');
card.classList.remove('bg-white', 'shadow-md');
} else {
card.classList.remove('seat-empty', 'border-dashed', 'bg-white/50');
card.classList.add('bg-white', 'shadow-md');
}
if(field === 'name') updateFontSize(nameInput, value);
saveState();
}
// 3. 鎖定功能
function toggleLock(index) {
state.seats[index].locked = !state.seats[index].locked;
saveState();
renderGrid();
}
// 4. 字體大小計算
function updateFontSize(el, text) {
if(!el) return;
el.className = el.className.replace(/text-size-\w+/g, '');
const len = text.length;
let cls = 'text-size-xl';
if (len <= 2) cls = 'text-size-4xl';
else if (len === 3) cls = 'text-size-3xl';
else if (len <= 5) cls = 'text-size-2xl';
else if (len <= 8) cls = 'text-size-xl';
else cls = 'text-size-sm';
el.classList.add(cls);
}
// --- 渲染 (View) ---
function renderGrid() {
els.grid.innerHTML = '';
els.grid.style.gridTemplateColumns = `repeat(${state.cols}, minmax(0, 1fr))`;
state.seats.forEach((seat, index) => {
const el = document.createElement('div');
el.id = `seat-${index}`;
el.draggable = !seat.locked;
let classes = `seat-card aspect-[4/3] relative rounded-xl border-2 transition-all duration-200 flex flex-col p-2 group `;
if (seat.locked) {
classes += `seat-locked border-gray-400 bg-gray-100 `;
} else if (seat.isEmpty) {
classes += `seat-empty border-dashed border-gray-300 bg-white/50 hover:bg-white hover:border-blue-300 `;
} else {
classes += `bg-white border-gray-800 shadow-md hover:shadow-lg `;
}
classes += `print:border-2 print:border-black print:shadow-none`;
el.className = classes;
if (!seat.locked) {
el.addEventListener('dragstart', (e) => handleDragStart(e, index));
el.addEventListener('dragover', handleDragOver);
el.addEventListener('drop', (e) => handleDrop(e, index));
el.addEventListener('dragend', handleDragEnd);
el.addEventListener('touchstart', (e) => handleTouchStart(e, index), {passive: false});
el.addEventListener('touchmove', (e) => handleTouchMove(e), {passive: false});
el.addEventListener('touchend', (e) => handleTouchEnd(e), {passive: false});
}
el.innerHTML = `
<div class="seat-actions absolute top-1 right-1 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity no-print">
<button onclick="toggleLock(${index})" class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-200" title="${seat.locked ? '解鎖' : '鎖定'}">
<i class="fa-solid ${seat.locked ? 'fa-lock text-red-500' : 'fa-lock-open'}"></i>
</button>
${!seat.locked ? '<div class="text-gray-300 p-1 cursor-grab"><i class="fa-solid fa-up-down-left-right"></i></div>' : ''}
</div>
${seat.locked ? '<div class="absolute top-1 left-1 text-gray-400 text-xs no-print"><i class="fa-solid fa-lock"></i></div>' : ''}
<div class="w-full flex justify-between items-start mb-1 z-0">
<input type="text" id="input-number-${index}"
value="${seat.number}"
placeholder="號"
${seat.locked ? 'disabled' : ''}
oninput="updateSeatData(${index}, 'number', this.value)"
class="w-1/2 text-sm text-gray-500 font-mono bg-transparent outline-none focus:text-blue-600 print:text-black">
</div>
<div class="flex-1 flex items-center justify-center w-full z-0">
<input type="text" id="input-name-${index}"
value="${seat.name}"
placeholder="${seat.isEmpty ? (seat.locked ? '鎖定' : '空位') : ''}"
${seat.locked ? 'disabled' : ''}
oninput="updateSeatData(${index}, 'name', this.value)"
class="w-full text-center bg-transparent outline-none transition-all ${seat.isEmpty ? 'placeholder-gray-300' : 'text-gray-900 print:text-black'} text-size-xl">
</div>
`;
els.grid.appendChild(el);
updateFontSize(document.getElementById(`input-name-${index}`), seat.name);
});
}
// --- Drag & Drop (Desktop) ---
let draggedIndex = null;
function handleDragStart(e, index) {
if (state.seats[index].locked) {
e.preventDefault();
return;
}
draggedIndex = index;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData('text/plain', index);
setTimeout(() => e.target.classList.add('dragging-source'), 0);
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
function handleDrop(e, targetIndex) {
e.preventDefault();
swapSeats(draggedIndex, targetIndex);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging-source');
draggedIndex = null;
}
// --- Touch Drag & Drop (Mobile Custom) ---
let touchSrcIndex = null;
let touchGhostEl = null;
function handleTouchStart(e, index) {
if (state.seats[index].locked) return;
if (e.target.tagName.toLowerCase() === 'input') return;
e.preventDefault();
touchSrcIndex = index;
const srcEl = document.getElementById(`seat-${index}`);
srcEl.classList.add('dragging-source');
touchGhostEl = document.createElement('div');
touchGhostEl.className = 'touch-ghost';
touchGhostEl.innerHTML = `
<span class="font-bold text-lg">${state.seats[index].name || '空位'}</span>
<span class="text-xs text-gray-500">${state.seats[index].number || '#'}</span>
`;
document.body.appendChild(touchGhostEl);
updateGhostPos(e.touches[0]);
}
function handleTouchMove(e) {
if (touchSrcIndex === null) return;
e.preventDefault();
updateGhostPos(e.touches[0]);
}
function handleTouchEnd(e) {
if (touchSrcIndex === null) return;
const touch = e.changedTouches[0];
const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
const seatCard = targetEl ? targetEl.closest('.seat-card') : null;
if (seatCard) {
const targetIndex = parseInt(seatCard.id.replace('seat-', ''));
if (!isNaN(targetIndex) && targetIndex !== touchSrcIndex) {
swapSeats(touchSrcIndex, targetIndex);
}
}
if (touchGhostEl) touchGhostEl.remove();
touchGhostEl = null;
document.getElementById(`seat-${touchSrcIndex}`).classList.remove('dragging-source');
touchSrcIndex = null;
}
function updateGhostPos(touch) {
if (touchGhostEl) {
touchGhostEl.style.left = `${touch.clientX - 60}px`;
touchGhostEl.style.top = `${touch.clientY - 60}px`;
}
}
// --- 通用邏輯 ---
function swapSeats(srcIdx, tgtIdx) {
if (srcIdx === null || tgtIdx === null || srcIdx === tgtIdx) return;
if (state.seats[srcIdx].locked || state.seats[tgtIdx].locked) {
alert('鎖定的座位無法交換!');
return;
}
const srcData = {
name: state.seats[srcIdx].name,
number: state.seats[srcIdx].number,
isEmpty: state.seats[srcIdx].isEmpty
};
const tgtData = {
name: state.seats[tgtIdx].name,
number: state.seats[tgtIdx].number,
isEmpty: state.seats[tgtIdx].isEmpty
};
state.seats[tgtIdx] = { ...state.seats[tgtIdx], ...srcData };
state.seats[srcIdx] = { ...state.seats[srcIdx], ...tgtData };
saveState();
renderGrid();
}
// --- 批量匯入邏輯 (已更新:支援座號+姓名) ---
function openImportModal() {
els.modal.classList.remove('hidden');
setTimeout(() => els.modal.querySelector('.modal-enter').classList.add('modal-enter-active'), 10);
}
function closeImportModal() {
els.modal.classList.add('hidden');
els.importText.value = '';
}
function processImport() {
const text = els.importText.value;
const overwrite = els.overwrite.checked;
if (!text.trim()) {
closeImportModal();
return;
}
// 1. 拆分行 (Split by newline)
const lines = text.split('\n').filter(line => line.trim());
let lineIdx = 0;
let modified = false;
for (let i = 0; i < state.seats.length; i++) {
if (lineIdx >= lines.length) break;
const seat = state.seats[i];
if (seat.locked) continue;
if (seat.isEmpty || overwrite) {
const rawLine = lines[lineIdx].trim();
let newNumber = '';
let newName = rawLine;
// 2. 使用 Regex 解析: 開頭是數字 + 分隔符(空白,點,逗號,Tab) + 姓名
// Group 1: 數字
// Group 2: 姓名部分
const match = rawLine.match(/^(\d+)[\s\.\,、\t]+(.+)$/);
if (match) {
newNumber = match[1];
newName = match[2].trim();
} else {
// 如果沒有明確分隔,檢查是否整行都只是數字 (例如只填了座號?)
// 或是整行只是名字。
if (/^\d+$/.test(rawLine)) {
newNumber = rawLine;
newName = ''; // 特殊情況:只給了座號
}
// 否則預設整行是姓名,座號為空
}
seat.name = newName;
seat.number = newNumber;
seat.isEmpty = false;
lineIdx++;
modified = true;
}
}
if (modified) {
saveState();
renderGrid();
alert(`成功匯入 ${lineIdx} 筆資料!`);
} else {
alert('沒有位置可供匯入,或是名單為空。');
}
closeImportModal();
}
// --- 存檔與讀檔 ---
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
showSaveStatus();
} catch (e) { console.error("Auto-save failed", e); }
}
function loadFromLocalStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
if (data.seats && Array.isArray(data.seats)) {
state = data;
els.inputRows.value = state.rows;
els.inputCols.value = state.cols;
els.title.value = state.title || "班級座位表";
return true;
}
}
} catch (e) { console.error("Load failed", e); }
return false;
}
function showSaveStatus() {
els.saveStatus.style.opacity = '1';
setTimeout(() => els.saveStatus.style.opacity = '0', 2000);
}
function clearAll() {
if (confirm('確定要清空所有未鎖定的座位嗎?')) {
state.seats.forEach(seat => {
if (!seat.locked) {
seat.name = '';
seat.number = '';
seat.isEmpty = true;
}
});
saveState();
renderGrid();
}
}
function saveToFile() {
const dataStr = JSON.stringify(state, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `座位表_${state.title}_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
init();
</script>
</body>
</html>