| | <!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> |
| | |
| | <script src="https://cdn.tailwindcss.com"></script> |
| | |
| | <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; |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | .dragging-source { |
| | opacity: 0.3; |
| | background-color: #e5e7eb; |
| | } |
| | |
| | |
| | .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; |
| | |
| | touch-action: none; |
| | position: relative; |
| | } |
| | |
| | |
| | .seat-locked { |
| | background-color: #f3f4f6 !important; |
| | border-color: #9ca3af !important; |
| | cursor: not-allowed !important; |
| | } |
| | .seat-locked input { |
| | color: #6b7280 !important; |
| | pointer-events: none; |
| | } |
| | |
| | |
| | .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"> |
| | |
| | |
| | <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);"> |
| | |
| | </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> |
| |
|
| | |
| | <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 王小明 2 李大同 03 陳雅婷 張偉..."></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> |
| |
|
| | |
| | <script> |
| | |
| | let state = { |
| | rows: 4, |
| | cols: 6, |
| | title: "班級座位表", |
| | seats: [] |
| | }; |
| | |
| | |
| | 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()}`; |
| | |
| | |
| | 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) => { |
| | |
| | }); |
| | |
| | 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(); |
| | } |
| | |
| | |
| | |
| | |
| | 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(); |
| | } |
| | |
| | |
| | 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() === ''); |
| | |
| | |
| | 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(); |
| | } |
| | |
| | |
| | function toggleLock(index) { |
| | state.seats[index].locked = !state.seats[index].locked; |
| | saveState(); |
| | renderGrid(); |
| | } |
| | |
| | |
| | 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); |
| | } |
| | |
| | |
| | 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); |
| | }); |
| | } |
| | |
| | |
| | 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; |
| | } |
| | |
| | |
| | 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; |
| | } |
| | |
| | |
| | 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; |
| | |
| | |
| | |
| | |
| | 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> |