Lashtw commited on
Commit
c829d91
·
verified ·
1 Parent(s): 167775b

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +688 -19
index.html CHANGED
@@ -1,19 +1,688 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>互動式座位表產生器 V2</title>
7
+ <!-- 引入 Tailwind CSS -->
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <!-- 引入 FontAwesome 圖標 -->
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
11
+
12
+ <style>
13
+ /* --- 列印專用樣式 --- */
14
+ @media print {
15
+ @page {
16
+ size: A4 landscape;
17
+ margin: 5mm;
18
+ }
19
+ body {
20
+ background: white;
21
+ -webkit-print-color-adjust: exact;
22
+ print-color-adjust: exact;
23
+ }
24
+ .no-print {
25
+ display: none !important;
26
+ }
27
+ .print-area {
28
+ width: 100% !important;
29
+ height: 100% !important;
30
+ padding: 0 !important;
31
+ border: none !important;
32
+ box-shadow: none !important;
33
+ overflow: visible !important;
34
+ }
35
+ /* 隱藏空位 */
36
+ .seat-empty {
37
+ visibility: hidden !important;
38
+ border: none !important;
39
+ }
40
+ /* 輸入框樣式重置 */
41
+ input {
42
+ border: none !important;
43
+ background: transparent !important;
44
+ padding: 0 !important;
45
+ margin: 0 !important;
46
+ }
47
+ input::placeholder {
48
+ color: transparent;
49
+ }
50
+ /* 隱藏操作圖示 */
51
+ .seat-actions {
52
+ display: none !important;
53
+ }
54
+ }
55
+
56
+ /* --- UI 樣式 --- */
57
+
58
+ /* 拖曳時的原始卡片樣式 */
59
+ .dragging-source {
60
+ opacity: 0.3;
61
+ background-color: #e5e7eb;
62
+ }
63
+
64
+ /* 觸控拖曳時的 Ghost 元素 (跟隨手指) */
65
+ .touch-ghost {
66
+ position: fixed;
67
+ pointer-events: none;
68
+ z-index: 9999;
69
+ opacity: 0.9;
70
+ transform: scale(1.05) rotate(2deg);
71
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
72
+ width: 120px; /* 約略寬度 */
73
+ background: white;
74
+ border: 2px solid #2563eb;
75
+ border-radius: 0.75rem;
76
+ padding: 0.5rem;
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: center;
80
+ justify-content: center;
81
+ }
82
+
83
+ /* 座位卡片基礎樣式 */
84
+ .seat-card {
85
+ user-select: none;
86
+ /* 關鍵:防止手機上觸控卡片時觸發瀏覽器捲動,讓 JS 接管 Touch 事件 */
87
+ touch-action: none;
88
+ position: relative;
89
+ }
90
+
91
+ /* 鎖定狀態 */
92
+ .seat-locked {
93
+ background-color: #f3f4f6 !important; /* gray-100 */
94
+ border-color: #9ca3af !important; /* gray-400 */
95
+ cursor: not-allowed !important;
96
+ }
97
+ .seat-locked input {
98
+ color: #6b7280 !important; /* gray-500 */
99
+ pointer-events: none;
100
+ }
101
+
102
+ /* Modal 動畫 */
103
+ .modal-enter {
104
+ opacity: 0;
105
+ transform: scale(0.95);
106
+ }
107
+ .modal-enter-active {
108
+ opacity: 1;
109
+ transform: scale(1);
110
+ transition: opacity 200ms, transform 200ms;
111
+ }
112
+
113
+ /* 自定義字體大小類別 */
114
+ .text-size-xl { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
115
+ .text-size-2xl { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
116
+ .text-size-3xl { font-size: 1.875rem; line-height: 2.25rem; font-weight: 700; }
117
+ .text-size-4xl { font-size: 2.25rem; line-height: 2.5rem; font-weight: 800; }
118
+ .text-size-sm { font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; }
119
+ </style>
120
+ </head>
121
+ <body class="bg-gray-100 font-sans text-gray-800 min-h-screen flex flex-col">
122
+
123
+ <!-- 頂部控制列 -->
124
+ <header class="bg-white border-b border-gray-200 p-3 shadow-sm no-print sticky top-0 z-40">
125
+ <div class="max-w-7xl mx-auto flex flex-wrap items-center justify-between gap-3">
126
+ <div class="flex items-center gap-2">
127
+ <div class="bg-blue-600 p-2 rounded-lg text-white">
128
+ <i class="fa-solid fa-chair text-lg"></i>
129
+ </div>
130
+ <h1 class="text-lg font-bold text-gray-800 hidden sm:block">座位表產生器 V2</h1>
131
+ </div>
132
+
133
+ <!-- 格子設定 -->
134
+ <div class="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-200">
135
+ <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)">
136
+ <span class="text-gray-400 text-xs">×</span>
137
+ <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)">
138
+ </div>
139
+
140
+ <!-- 功能按鈕區 -->
141
+ <div class="flex items-center gap-2 flex-wrap">
142
+ <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">
143
+ <i class="fa-solid fa-file-excel"></i> <span class="hidden sm:inline">批量匯入</span>
144
+ </button>
145
+
146
+ <div class="h-5 w-px bg-gray-300 mx-1 hidden sm:block"></div>
147
+
148
+ <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="清空所有">
149
+ <i class="fa-solid fa-trash-can"></i>
150
+ </button>
151
+
152
+ <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)">
153
+ <i class="fa-solid fa-download"></i>
154
+ </button>
155
+
156
+ <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">
157
+ <i class="fa-solid fa-print"></i> <span>匯出 PDF</span>
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </header>
162
+
163
+ <!-- 主內容區 -->
164
+ <main class="flex-1 p-4 sm:p-8 overflow-auto flex justify-center items-start print-area bg-gray-100/50">
165
+
166
+ <!-- A4 紙張容器 (Landscape) -->
167
+ <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">
168
+
169
+ <!-- 儲存狀態提示 -->
170
+ <div id="save-status" class="absolute top-2 right-2 text-xs text-green-600 opacity-0 transition-opacity duration-500 no-print">
171
+ <i class="fa-solid fa-check-circle"></i> 已自動儲存
172
+ </div>
173
+
174
+ <!-- 教室標題 -->
175
+ <div class="w-full mb-6 text-center group">
176
+ <input type="text" id="classroom-title" value="班級座位表"
177
+ 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">
178
+ </div>
179
+
180
+ <!-- 座位網格容器 -->
181
+ <div id="seat-grid" class="grid gap-4 w-full mb-12 flex-1" style="grid-template-columns: repeat(6, 1fr);">
182
+ <!-- 座位將由 JavaScript 動態生成 -->
183
+ </div>
184
+
185
+ <!-- 講台 / 黑板 -->
186
+ <div class="w-full mt-auto mb-2 text-center">
187
+ <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">
188
+ <span class="text-amber-800 font-bold tracking-[0.5em] text-base print:text-black">講 台 / 黑 板</span>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- 頁尾資訊 -->
193
+ <div class="w-full hidden print:flex justify-between text-xs text-gray-500 mt-4 border-t border-gray-200 pt-2">
194
+ <span>導師簽名:________________</span>
195
+ <span id="print-date"></span>
196
+ </div>
197
+ </div>
198
+ </main>
199
+
200
+ <!-- 底部提示 -->
201
+ <div class="bg-white border-t p-2 text-center text-xs text-gray-500 no-print">
202
+ <span class="hidden sm:inline">提示:</span>
203
+ <span class="mr-2"><i class="fa-solid fa-lock"></i> 可鎖定座位</span>
204
+ <span class="mr-2"><i class="fa-regular fa-hand-pointer"></i> 支援觸控拖曳</span>
205
+ <span><i class="fa-solid fa-floppy-disk"></i> 自動存檔中</span>
206
+ </div>
207
+
208
+ <!-- 批量匯入 Modal (已更新) -->
209
+ <div id="import-modal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center backdrop-blur-sm">
210
+ <div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh] modal-enter">
211
+ <div class="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50 rounded-t-xl">
212
+ <h3 class="font-bold text-lg text-gray-800"><i class="fa-solid fa-paste text-green-600 mr-2"></i>批量匯入名單</h3>
213
+ <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">
214
+ <i class="fa-solid fa-xmark"></i>
215
+ </button>
216
+ </div>
217
+ <div class="p-6 flex-1 overflow-y-auto">
218
+ <p class="text-sm text-gray-600 mb-2">請貼上學生名單(支援 Excel 直接複製):</p>
219
+ <div class="text-xs text-gray-500 mb-3 bg-blue-50 p-3 rounded border border-blue-100 leading-relaxed">
220
+ <i class="fa-solid fa-circle-info mr-1 text-blue-600"></i> <b>格式說明:</b><br>
221
+ 每行輸入一位學生。支援「座號+姓名」或「僅姓名」。<br>
222
+ 例如:<br>
223
+ <code class="bg-white px-1 rounded border">1 王小明</code> (推薦)<br>
224
+ <code class="bg-white px-1 rounded border">2.李大同</code><br>
225
+ <code class="bg-white px-1 rounded border">陳雅婷</code>
226
+ </div>
227
+ <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>
228
+
229
+ <div class="mt-4 flex items-center gap-2">
230
+ <input type="checkbox" id="overwrite-check" class="rounded text-blue-600 focus:ring-blue-500">
231
+ <label for="overwrite-check" class="text-sm text-gray-700 cursor-pointer">強制覆蓋現有內容 (包含已填寫的格子)</label>
232
+ </div>
233
+ </div>
234
+ <div class="p-4 border-t border-gray-100 flex justify-end gap-3 bg-gray-50 rounded-b-xl">
235
+ <button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg transition-colors font-medium">取消</button>
236
+ <button onclick="processImport()" class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg shadow transition-colors font-medium">
237
+ <i class="fa-solid fa-check mr-1"></i> 開始匯入
238
+ </button>
239
+ </div>
240
+ </div>
241
+ </div>
242
+
243
+ <!-- JavaScript 邏輯 -->
244
+ <script>
245
+ // --- 核心狀態 ---
246
+ let state = {
247
+ rows: 4,
248
+ cols: 6,
249
+ title: "班級座位表",
250
+ seats: [] // { id, name, number, isEmpty, locked }
251
+ };
252
+
253
+ // DOM 緩存
254
+ const els = {
255
+ grid: document.getElementById('seat-grid'),
256
+ inputRows: document.getElementById('input-rows'),
257
+ inputCols: document.getElementById('input-cols'),
258
+ title: document.getElementById('classroom-title'),
259
+ date: document.getElementById('print-date'),
260
+ modal: document.getElementById('import-modal'),
261
+ importText: document.getElementById('import-textarea'),
262
+ overwrite: document.getElementById('overwrite-check'),
263
+ saveStatus: document.getElementById('save-status')
264
+ };
265
+
266
+ const STORAGE_KEY = 'seating-chart-autosave-v2';
267
+
268
+ // --- 初始化與生命週期 ---
269
+ function init() {
270
+ els.date.textContent = `製表日期:${new Date().toLocaleDateString()}`;
271
+
272
+ // 嘗試讀取 Auto-save
273
+ if (!loadFromLocalStorage()) {
274
+ initializeSeats(state.rows, state.cols);
275
+ }
276
+
277
+ // 綁定基礎事件
278
+ els.inputRows.addEventListener('change', updateGridDimensions);
279
+ els.inputCols.addEventListener('change', updateGridDimensions);
280
+ els.title.addEventListener('input', (e) => {
281
+ state.title = e.target.value;
282
+ saveState();
283
+ });
284
+
285
+ // 視窗關閉前警告 (防呆)
286
+ window.addEventListener('beforeunload', (e) => {
287
+ // 這裡其實不需要做太多,因為我們有 auto-save,但為了保險起見
288
+ });
289
+
290
+ renderGrid();
291
+ }
292
+
293
+ // 初始化資料結構
294
+ function initializeSeats(rows, cols) {
295
+ const total = rows * cols;
296
+ state.seats = Array.from({ length: total }, (_, i) => ({
297
+ id: i,
298
+ name: '',
299
+ number: '',
300
+ isEmpty: true,
301
+ locked: false
302
+ }));
303
+ state.rows = rows;
304
+ state.cols = cols;
305
+ saveState(); // 初始存檔
306
+ }
307
+
308
+ // --- 核心邏輯 ---
309
+
310
+ // 1. Grid 調整
311
+ function updateGridDimensions() {
312
+ const newRows = parseInt(els.inputRows.value) || 1;
313
+ const newCols = parseInt(els.inputCols.value) || 1;
314
+ const total = newRows * newCols;
315
+
316
+ // 重建陣列但保留舊資料
317
+ const newSeats = [];
318
+ for (let i = 0; i < total; i++) {
319
+ if (i < state.seats.length) {
320
+ newSeats.push({ ...state.seats[i] });
321
+ } else {
322
+ newSeats.push({ id: i, name: '', number: '', isEmpty: true, locked: false });
323
+ }
324
+ }
325
+
326
+ state.rows = newRows;
327
+ state.cols = newCols;
328
+ state.seats = newSeats;
329
+
330
+ saveState();
331
+ renderGrid();
332
+ }
333
+
334
+ // 2. 單一座位更新
335
+ function updateSeatData(index, field, value) {
336
+ const seat = state.seats[index];
337
+ if (seat.locked) return; // 鎖定防呆
338
+
339
+ seat[field] = value;
340
+ seat.isEmpty = (seat.name.trim() === '' && seat.number.trim() === '');
341
+
342
+ // 局部 DOM 更新 (優化效能)
343
+ const card = document.getElementById(`seat-${index}`);
344
+ const input = document.getElementById(`input-${field}-${index}`);
345
+ const nameInput = document.getElementById(`input-name-${index}`);
346
+
347
+ if (seat.isEmpty) {
348
+ card.classList.add('seat-empty', 'border-dashed', 'bg-white/50');
349
+ card.classList.remove('bg-white', 'shadow-md');
350
+ } else {
351
+ card.classList.remove('seat-empty', 'border-dashed', 'bg-white/50');
352
+ card.classList.add('bg-white', 'shadow-md');
353
+ }
354
+
355
+ if(field === 'name') updateFontSize(nameInput, value);
356
+
357
+ saveState();
358
+ }
359
+
360
+ // 3. 鎖定功能
361
+ function toggleLock(index) {
362
+ state.seats[index].locked = !state.seats[index].locked;
363
+ saveState();
364
+ renderGrid();
365
+ }
366
+
367
+ // 4. 字體大小計算
368
+ function updateFontSize(el, text) {
369
+ if(!el) return;
370
+ el.className = el.className.replace(/text-size-\w+/g, '');
371
+ const len = text.length;
372
+ let cls = 'text-size-xl';
373
+ if (len <= 2) cls = 'text-size-4xl';
374
+ else if (len === 3) cls = 'text-size-3xl';
375
+ else if (len <= 5) cls = 'text-size-2xl';
376
+ else if (len <= 8) cls = 'text-size-xl';
377
+ else cls = 'text-size-sm';
378
+ el.classList.add(cls);
379
+ }
380
+
381
+ // --- 渲染 (View) ---
382
+ function renderGrid() {
383
+ els.grid.innerHTML = '';
384
+ els.grid.style.gridTemplateColumns = `repeat(${state.cols}, minmax(0, 1fr))`;
385
+
386
+ state.seats.forEach((seat, index) => {
387
+ const el = document.createElement('div');
388
+ el.id = `seat-${index}`;
389
+ el.draggable = !seat.locked;
390
+
391
+ let classes = `seat-card aspect-[4/3] relative rounded-xl border-2 transition-all duration-200 flex flex-col p-2 group `;
392
+ if (seat.locked) {
393
+ classes += `seat-locked border-gray-400 bg-gray-100 `;
394
+ } else if (seat.isEmpty) {
395
+ classes += `seat-empty border-dashed border-gray-300 bg-white/50 hover:bg-white hover:border-blue-300 `;
396
+ } else {
397
+ classes += `bg-white border-gray-800 shadow-md hover:shadow-lg `;
398
+ }
399
+ classes += `print:border-2 print:border-black print:shadow-none`;
400
+
401
+ el.className = classes;
402
+
403
+ if (!seat.locked) {
404
+ el.addEventListener('dragstart', (e) => handleDragStart(e, index));
405
+ el.addEventListener('dragover', handleDragOver);
406
+ el.addEventListener('drop', (e) => handleDrop(e, index));
407
+ el.addEventListener('dragend', handleDragEnd);
408
+ el.addEventListener('touchstart', (e) => handleTouchStart(e, index), {passive: false});
409
+ el.addEventListener('touchmove', (e) => handleTouchMove(e), {passive: false});
410
+ el.addEventListener('touchend', (e) => handleTouchEnd(e), {passive: false});
411
+ }
412
+
413
+ el.innerHTML = `
414
+ <div class="seat-actions absolute top-1 right-1 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity no-print">
415
+ <button onclick="toggleLock(${index})" class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-200" title="${seat.locked ? '解鎖' : '鎖定'}">
416
+ <i class="fa-solid ${seat.locked ? 'fa-lock text-red-500' : 'fa-lock-open'}"></i>
417
+ </button>
418
+ ${!seat.locked ? '<div class="text-gray-300 p-1 cursor-grab"><i class="fa-solid fa-up-down-left-right"></i></div>' : ''}
419
+ </div>
420
+ ${seat.locked ? '<div class="absolute top-1 left-1 text-gray-400 text-xs no-print"><i class="fa-solid fa-lock"></i></div>' : ''}
421
+
422
+ <div class="w-full flex justify-between items-start mb-1 z-0">
423
+ <input type="text" id="input-number-${index}"
424
+ value="${seat.number}"
425
+ placeholder="號"
426
+ ${seat.locked ? 'disabled' : ''}
427
+ oninput="updateSeatData(${index}, 'number', this.value)"
428
+ class="w-1/2 text-sm text-gray-500 font-mono bg-transparent outline-none focus:text-blue-600 print:text-black">
429
+ </div>
430
+
431
+ <div class="flex-1 flex items-center justify-center w-full z-0">
432
+ <input type="text" id="input-name-${index}"
433
+ value="${seat.name}"
434
+ placeholder="${seat.isEmpty ? (seat.locked ? '鎖定' : '空位') : ''}"
435
+ ${seat.locked ? 'disabled' : ''}
436
+ oninput="updateSeatData(${index}, 'name', this.value)"
437
+ 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">
438
+ </div>
439
+ `;
440
+
441
+ els.grid.appendChild(el);
442
+ updateFontSize(document.getElementById(`input-name-${index}`), seat.name);
443
+ });
444
+ }
445
+
446
+ // --- Drag & Drop (Desktop) ---
447
+ let draggedIndex = null;
448
+
449
+ function handleDragStart(e, index) {
450
+ if (state.seats[index].locked) {
451
+ e.preventDefault();
452
+ return;
453
+ }
454
+ draggedIndex = index;
455
+ e.dataTransfer.effectAllowed = "move";
456
+ e.dataTransfer.setData('text/plain', index);
457
+ setTimeout(() => e.target.classList.add('dragging-source'), 0);
458
+ }
459
+
460
+ function handleDragOver(e) {
461
+ e.preventDefault();
462
+ e.dataTransfer.dropEffect = "move";
463
+ }
464
+
465
+ function handleDrop(e, targetIndex) {
466
+ e.preventDefault();
467
+ swapSeats(draggedIndex, targetIndex);
468
+ }
469
+
470
+ function handleDragEnd(e) {
471
+ e.target.classList.remove('dragging-source');
472
+ draggedIndex = null;
473
+ }
474
+
475
+ // --- Touch Drag & Drop (Mobile Custom) ---
476
+ let touchSrcIndex = null;
477
+ let touchGhostEl = null;
478
+
479
+ function handleTouchStart(e, index) {
480
+ if (state.seats[index].locked) return;
481
+ if (e.target.tagName.toLowerCase() === 'input') return;
482
+
483
+ e.preventDefault();
484
+ touchSrcIndex = index;
485
+ const srcEl = document.getElementById(`seat-${index}`);
486
+ srcEl.classList.add('dragging-source');
487
+
488
+ touchGhostEl = document.createElement('div');
489
+ touchGhostEl.className = 'touch-ghost';
490
+ touchGhostEl.innerHTML = `
491
+ <span class="font-bold text-lg">${state.seats[index].name || '空位'}</span>
492
+ <span class="text-xs text-gray-500">${state.seats[index].number || '#'}</span>
493
+ `;
494
+ document.body.appendChild(touchGhostEl);
495
+ updateGhostPos(e.touches[0]);
496
+ }
497
+
498
+ function handleTouchMove(e) {
499
+ if (touchSrcIndex === null) return;
500
+ e.preventDefault();
501
+ updateGhostPos(e.touches[0]);
502
+ }
503
+
504
+ function handleTouchEnd(e) {
505
+ if (touchSrcIndex === null) return;
506
+ const touch = e.changedTouches[0];
507
+ const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
508
+ const seatCard = targetEl ? targetEl.closest('.seat-card') : null;
509
+
510
+ if (seatCard) {
511
+ const targetIndex = parseInt(seatCard.id.replace('seat-', ''));
512
+ if (!isNaN(targetIndex) && targetIndex !== touchSrcIndex) {
513
+ swapSeats(touchSrcIndex, targetIndex);
514
+ }
515
+ }
516
+ if (touchGhostEl) touchGhostEl.remove();
517
+ touchGhostEl = null;
518
+ document.getElementById(`seat-${touchSrcIndex}`).classList.remove('dragging-source');
519
+ touchSrcIndex = null;
520
+ }
521
+
522
+ function updateGhostPos(touch) {
523
+ if (touchGhostEl) {
524
+ touchGhostEl.style.left = `${touch.clientX - 60}px`;
525
+ touchGhostEl.style.top = `${touch.clientY - 60}px`;
526
+ }
527
+ }
528
+
529
+ // --- 通用邏輯 ---
530
+ function swapSeats(srcIdx, tgtIdx) {
531
+ if (srcIdx === null || tgtIdx === null || srcIdx === tgtIdx) return;
532
+ if (state.seats[srcIdx].locked || state.seats[tgtIdx].locked) {
533
+ alert('鎖定的座位無法交換!');
534
+ return;
535
+ }
536
+
537
+ const srcData = {
538
+ name: state.seats[srcIdx].name,
539
+ number: state.seats[srcIdx].number,
540
+ isEmpty: state.seats[srcIdx].isEmpty
541
+ };
542
+ const tgtData = {
543
+ name: state.seats[tgtIdx].name,
544
+ number: state.seats[tgtIdx].number,
545
+ isEmpty: state.seats[tgtIdx].isEmpty
546
+ };
547
+
548
+ state.seats[tgtIdx] = { ...state.seats[tgtIdx], ...srcData };
549
+ state.seats[srcIdx] = { ...state.seats[srcIdx], ...tgtData };
550
+
551
+ saveState();
552
+ renderGrid();
553
+ }
554
+
555
+ // --- 批量匯入邏輯 (已更新:支援座號+姓名) ---
556
+ function openImportModal() {
557
+ els.modal.classList.remove('hidden');
558
+ setTimeout(() => els.modal.querySelector('.modal-enter').classList.add('modal-enter-active'), 10);
559
+ }
560
+
561
+ function closeImportModal() {
562
+ els.modal.classList.add('hidden');
563
+ els.importText.value = '';
564
+ }
565
+
566
+ function processImport() {
567
+ const text = els.importText.value;
568
+ const overwrite = els.overwrite.checked;
569
+
570
+ if (!text.trim()) {
571
+ closeImportModal();
572
+ return;
573
+ }
574
+
575
+ // 1. 拆分行 (Split by newline)
576
+ const lines = text.split('\n').filter(line => line.trim());
577
+ let lineIdx = 0;
578
+ let modified = false;
579
+
580
+ for (let i = 0; i < state.seats.length; i++) {
581
+ if (lineIdx >= lines.length) break;
582
+
583
+ const seat = state.seats[i];
584
+ if (seat.locked) continue;
585
+
586
+ if (seat.isEmpty || overwrite) {
587
+ const rawLine = lines[lineIdx].trim();
588
+ let newNumber = '';
589
+ let newName = rawLine;
590
+
591
+ // 2. 使用 Regex 解析: 開頭是數字 + 分隔符(空白,點,逗號,Tab) + 姓名
592
+ // Group 1: 數字
593
+ // Group 2: 姓名部分
594
+ const match = rawLine.match(/^(\d+)[\s\.\,、\t]+(.+)$/);
595
+
596
+ if (match) {
597
+ newNumber = match[1];
598
+ newName = match[2].trim();
599
+ } else {
600
+ // 如果沒有明確分隔,檢查是否整行都只是數字 (例如只填了座號?)
601
+ // 或是整行只是名字。
602
+ if (/^\d+$/.test(rawLine)) {
603
+ newNumber = rawLine;
604
+ newName = ''; // 特殊情況:只給了座號
605
+ }
606
+ // 否則預設整行是姓名,座號為空
607
+ }
608
+
609
+ seat.name = newName;
610
+ seat.number = newNumber;
611
+ seat.isEmpty = false;
612
+
613
+ lineIdx++;
614
+ modified = true;
615
+ }
616
+ }
617
+
618
+ if (modified) {
619
+ saveState();
620
+ renderGrid();
621
+ alert(`成功匯入 ${lineIdx} 筆資料!`);
622
+ } else {
623
+ alert('沒有位置可供匯入,或是名單為空。');
624
+ }
625
+
626
+ closeImportModal();
627
+ }
628
+
629
+ // --- 存檔與讀檔 ---
630
+ function saveState() {
631
+ try {
632
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
633
+ showSaveStatus();
634
+ } catch (e) { console.error("Auto-save failed", e); }
635
+ }
636
+
637
+ function loadFromLocalStorage() {
638
+ try {
639
+ const raw = localStorage.getItem(STORAGE_KEY);
640
+ if (raw) {
641
+ const data = JSON.parse(raw);
642
+ if (data.seats && Array.isArray(data.seats)) {
643
+ state = data;
644
+ els.inputRows.value = state.rows;
645
+ els.inputCols.value = state.cols;
646
+ els.title.value = state.title || "班級座位表";
647
+ return true;
648
+ }
649
+ }
650
+ } catch (e) { console.error("Load failed", e); }
651
+ return false;
652
+ }
653
+
654
+ function showSaveStatus() {
655
+ els.saveStatus.style.opacity = '1';
656
+ setTimeout(() => els.saveStatus.style.opacity = '0', 2000);
657
+ }
658
+
659
+ function clearAll() {
660
+ if (confirm('確定要清空所有未鎖定的座位嗎?')) {
661
+ state.seats.forEach(seat => {
662
+ if (!seat.locked) {
663
+ seat.name = '';
664
+ seat.number = '';
665
+ seat.isEmpty = true;
666
+ }
667
+ });
668
+ saveState();
669
+ renderGrid();
670
+ }
671
+ }
672
+
673
+ function saveToFile() {
674
+ const dataStr = JSON.stringify(state, null, 2);
675
+ const blob = new Blob([dataStr], { type: "application/json" });
676
+ const url = URL.createObjectURL(blob);
677
+ const a = document.createElement('a');
678
+ a.href = url;
679
+ a.download = `座位表_${state.title}_${new Date().toISOString().slice(0,10)}.json`;
680
+ document.body.appendChild(a);
681
+ a.click();
682
+ document.body.removeChild(a);
683
+ }
684
+
685
+ init();
686
+ </script>
687
+ </body>
688
+ </html>