0. 作品介紹
封面
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: 分頁1
A 欄: 座號
B 欄: 姓名
C 欄: 性別
D 欄: 身高
📄 解析結果(自動表格)
分頁:分頁1
| A 欄 | B 欄 | C 欄 | D 欄 |
|---|---|---|---|
| 座號 | 姓名 | 性別 | 身高 |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 Code.gs。
// ============================================================
// 學生座位產生器 — Code.gs (Google Apps Script 後端)
// ============================================================
// ---------- 全域設定 ----------
// 不寫死 ID,自動抓「當前試算表」(從試算表開啟 Apps Script 時使用)
const SHEET_NAME_STUDENTS = '學生資料';
const SHEET_NAME_SETTINGS = '設定';
const SHEET_NAME_HISTORY = '歷史紀錄';
// 統一取得試算表的入口,永遠抓當前綁定的試算表
function getSpreadsheet() {
return SpreadsheetApp.getActiveSpreadsheet();
}
// ============================================================
// 1. 入口:開啟 Web App
// ============================================================
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('學生座位產生器')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// ============================================================
// 2. 初始化試算表(首次使用自動建立工作表和範例資料)
// ============================================================
function initSpreadsheet() {
const ss = getSpreadsheet();
// --- 學生資料工作表 ---
let studentSheet = ss.getSheetByName(SHEET_NAME_STUDENTS);
if (!studentSheet) {
studentSheet = ss.insertSheet(SHEET_NAME_STUDENTS);
const headers = ['座號', '姓名', '性別', '身高(cm)', '固定前排', '固定座位(行,列)', '不相鄰座號', '不同組座號'];
studentSheet.getRange(1, 1, 1, headers.length).setValues([headers])
.setBackground('#2d3748').setFontColor('#ffffff').setFontWeight('bold');
// 範例資料(30人)
const sample = [
['S001','王小明','男',175,'','','',''],['S002','李小華','女',162,'','','',''],
['S003','張小美','女',158,'','','',''],['S004','陳大為','男',180,'','','',''],
['S005','林小玲','女',165,'','','',''],['S006','黃志偉','男',172,'','','',''],
['S007','劉美珍','女',160,'','','',''],['S008','吳建宏','男',168,'','','',''],
['S009','蔡雅婷','女',163,'','','',''],['S010','鄭文豪','男',177,'','','',''],
['S011','許小芬','女',155,'','','',''],['S012','謝明宏','男',183,'','','',''],
['S013','周雅雯','女',167,'','','',''],['S014','江志明','男',170,'','','',''],
['S015','方淑君','女',161,'','','',''],['S016','盧建志','男',174,'','','',''],
['S017','潘美麗','女',159,'','','',''],['S018','郭宇翔','男',178,'','','',''],
['S019','范雅惠','女',164,'','','',''],['S020','洪文彬','男',171,'','','',''],
['S021','沈佳琪','女',166,'','','',''],['S022','葉建豪','男',176,'','','',''],
['S023','曾淑芳','女',157,'','','',''],['S024','楊國棟','男',182,'','','',''],
['S025','賴美惠','女',162,'','','',''],['S026','邱志遠','男',169,'','','',''],
['S027','朱雅玲','女',160,'','','',''],['S028','施建邦','男',173,'','','',''],
['S029','馮淑萍','女',158,'','','',''],['S030','程文峰','男',179,'','','',''],
];
studentSheet.getRange(2, 1, sample.length, sample[0].length).setValues(sample);
studentSheet.setColumnWidths(1, 8, 120);
studentSheet.setFrozenRows(1);
}
// --- 設定工作表 ---
let settingSheet = ss.getSheetByName(SHEET_NAME_SETTINGS);
if (!settingSheet) {
settingSheet = ss.insertSheet(SHEET_NAME_SETTINGS);
settingSheet.getRange('A1').setValue('設定項目').setFontWeight('bold').setBackground('#2d3748').setFontColor('#fff');
settingSheet.getRange('B1').setValue('值').setFontWeight('bold').setBackground('#2d3748').setFontColor('#fff');
const defaults = [
['座位模式','班級座位'],['黑板方向','前'],['顯示身高','是'],
['排列方式','隨機'],['性別顏色_男','#bee3f8'],['性別顏色_女','#fed7e2']
];
settingSheet.getRange(2, 1, defaults.length, 2).setValues(defaults);
}
// --- 歷史紀錄工作表 ---
let historySheet = ss.getSheetByName(SHEET_NAME_HISTORY);
if (!historySheet) {
historySheet = ss.insertSheet(SHEET_NAME_HISTORY);
historySheet.getRange(1,1,1,3).setValues([['時間戳記','說明','座位資料(JSON)']])
.setBackground('#2d3748').setFontColor('#fff').setFontWeight('bold');
}
return { success: true, message: '試算表初始化完成' };
}
// ============================================================
// 3. 讀取學生資料
// ============================================================
function getStudents() {
try {
const ss = getSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME_STUDENTS);
if (!sheet) { initSpreadsheet(); return getStudents(); }
const data = sheet.getDataRange().getValues();
if (data.length < 2) return [];
const headers = data[0];
const students = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[0]) continue; // 跳過空行
students.push({
id: String(row[0]).trim(),
name: String(row[1]).trim(),
gender: String(row[2]).trim(),
height: Number(row[3]) || 0,
fixFront: String(row[4]).trim() === '是' || String(row[4]).trim() === 'Y' || String(row[4]).trim() === 'y',
fixSeat: String(row[5]).trim(), // "行,列" 格式,如 "1,3"
noAdjacent:String(row[6]).trim(), // 逗號分隔座號
noSameGroup:String(row[7]).trim() // 逗號分隔座號
});
}
return students;
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 4. 讀取設定
// ============================================================
function getSettings() {
try {
const ss = getSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME_SETTINGS);
if (!sheet) return {};
const data = sheet.getDataRange().getValues();
const settings = {};
for (let i = 1; i < data.length; i++) {
if (data[i][0]) settings[data[i][0]] = data[i][1];
}
return settings;
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 5. 儲存設定回試算表
// ============================================================
function saveSettings(settingsObj) {
try {
const ss = getSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME_SETTINGS);
if (!sheet) { initSpreadsheet(); sheet = ss.getSheetByName(SHEET_NAME_SETTINGS); }
const data = sheet.getDataRange().getValues();
for (const [key, val] of Object.entries(settingsObj)) {
let found = false;
for (let i = 1; i < data.length; i++) {
if (data[i][0] === key) {
sheet.getRange(i + 1, 2).setValue(val);
found = true;
break;
}
}
if (!found) {
sheet.appendRow([key, val]);
}
}
return { success: true };
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 6. 核心:產生座位
// ============================================================
function generateSeats(params, studentsJson) {
// studentsJson:前端貼上匯入的學生資料(JSON 字串)
// 若有傳入則優先使用,否則從試算表讀取
try {
let students;
if (studentsJson) {
// 前端傳來的學生資料
students = JSON.parse(studentsJson);
} else {
// 從試算表讀取
students = getStudents();
if (students.error) return students;
if (!students.length) return { error: '試算表無學生資料,請使用「貼上輸入」匯入名單' };
}
const mode = params.mode || 'class';
const arrangement = params.arrangement || ['random'];
const rows = parseInt(params.rows) || 5;
const cols = parseInt(params.cols) || 6;
const groups = parseInt(params.groups) || 6;
// --- Step 1: 基礎排序 ---
let pool = [...students];
// 棋盤式:不在這裡處理,交給 buildClassSeats 矩陣階段處理
// 身高排序:矮→高,pool[0] 最矮坐前排
if (arrangement.includes('height')) {
pool.sort((a, b) => a.height - b.height);
} else if (!arrangement.includes('checkerboard')) {
// 非棋盤、非身高 → 隨機
shuffleArray(pool);
} else {
// 純棋盤模式:各性別內部先隨機
shuffleArray(pool);
}
// --- Step 2: 固定前排學生移至 pool 前端 ---
if (arrangement.includes('fixFront')) {
const fixStudents = pool.filter(s => s.fixFront);
const others = pool.filter(s => !s.fixFront);
pool = [...fixStudents, ...others];
}
// --- Step 3: 建立座位矩陣(確保每次結果不同,最多嘗試10次)---
const prevHash = params.prevHash || '';
let result;
let violations = [];
let attempts = 0;
const MAX_ATTEMPTS = 10;
do {
// 重新隨機 pool(確保每次嘗試都有新的隨機性)
if (attempts > 0) {
if (arrangement.includes('height')) {
pool.sort((a, b) => a.height - b.height);
} else {
shuffleArray(pool);
}
if (arrangement.includes('fixFront')) {
const fix = pool.filter(s => s.fixFront);
const rest = pool.filter(s => !s.fixFront);
pool = [...fix, ...rest];
}
}
if (mode === 'class') {
const built = buildClassSeats(pool, rows, cols, arrangement, params);
result = built.seats;
violations = built.violations || [];
} else {
result = buildGroupSeats(pool, groups, arrangement, params);
violations = [];
}
attempts++;
} while (attempts < MAX_ATTEMPTS && hashSeats(result) === prevHash);
// --- Step 4: 存入歷史 ---
saveHistory(params, result);
return { success: true, seats: result, mode: mode, hash: hashSeats(result), violations: violations };
} catch(e) {
return { error: e.message };
}
}
// ---- 6a. 班級座位 (rows x cols 矩陣) ----
function buildClassSeats(pool, rows, cols, arrangement, params) {
const useCheckerboard = arrangement.includes('checkerboard');
const useHeight = arrangement.includes('height');
const useFixFront = arrangement.includes('fixFront');
// 建立空矩陣
const matrix = Array.from({ length: rows }, () => Array(cols).fill(null));
// ── 固定特定座位(行,列 指定)──
const fixedSeatIds = {};
pool.forEach(s => {
if (s.fixSeat) {
const parts = s.fixSeat.split(',');
if (parts.length === 2) {
const r = parseInt(parts[0]) - 1;
const c = parseInt(parts[1]) - 1;
if (r >= 0 && r < rows && c >= 0 && c < cols) {
matrix[r][c] = s;
fixedSeatIds[s.id] = true;
}
}
}
});
// ── 分離固定前排 vs 一般學生 ──
const fixFrontStudents = pool.filter(s => s.fixFront && !fixedSeatIds[s.id]);
let remaining = pool.filter(s => !s.fixFront && !fixedSeatIds[s.id]);
if (useCheckerboard) {
// ══════════════════════════════════════════════════
// 棋盤式:依 (r+c)%2 決定性別
// 固定前排優先填行1,其餘依身高/隨機填行2以後
// ══════════════════════════════════════════════════
// 棋盤起始性別隨機
const maleOnEven = Math.random() < 0.5;
// 收集各行偶數格/奇數格(排除已固定座位)
const evenByRow = Array.from({length: rows}, () => []);
const oddByRow = Array.from({length: rows}, () => []);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (matrix[r][c]) continue;
if ((r + c) % 2 === 0) evenByRow[r].push({r, c});
else oddByRow[r].push({r, c});
}
}
// ── 固定前排學生填入行1(棋盤規則)──
if (useFixFront && fixFrontStudents.length > 0) {
let ffMales = fixFrontStudents.filter(s => s.gender === '男');
let ffFemales = fixFrontStudents.filter(s => s.gender === '女');
shuffleArray(ffMales);
shuffleArray(ffFemales);
const evenGroup = maleOnEven ? ffMales : ffFemales;
const oddGroup = maleOnEven ? ffFemales : ffMales;
// 行1偶數格填固定前排的偶數性別
let ei = 0;
for (const slot of evenByRow[0]) {
if (ei < evenGroup.length) { matrix[slot.r][slot.c] = evenGroup[ei++]; }
else break;
}
// 行1奇數格填固定前排的奇數性別
let oi = 0;
for (const slot of oddByRow[0]) {
if (oi < oddGroup.length) { matrix[slot.r][slot.c] = oddGroup[oi++]; }
else break;
}
// 固定前排人數超過行1容量 → 剩餘放入 remaining 最前面(填行2)
const usedFixFront = new Set();
for (let c = 0; c < cols; c++) {
if (matrix[0][c] && fixFrontStudents.find(s => s.id === matrix[0][c].id)) {
usedFixFront.add(matrix[0][c].id);
}
}
const overflowFix = fixFrontStudents.filter(s => !usedFixFront.has(s.id));
remaining = [...overflowFix, ...remaining];
} else if (fixFrontStudents.length > 0) {
// 有固定前排但沒勾選 fixFront 條件 → 加回一般隊列
remaining = [...fixFrontStudents, ...remaining];
}
// ── 一般學生:依身高排序或隨機,填行1剩餘格子+行2以後 ──
let remMales = remaining.filter(s => s.gender === '男');
let remFemales = remaining.filter(s => s.gender === '女');
if (useHeight) {
remMales.sort((a, b) => a.height - b.height);
remFemales.sort((a, b) => a.height - b.height);
} else {
shuffleArray(remMales);
shuffleArray(remFemales);
}
const evenGroup2 = maleOnEven ? remMales : remFemales;
const oddGroup2 = maleOnEven ? remFemales : remMales;
let ei2 = 0, oi2 = 0;
for (let r = 0; r < rows; r++) {
// 偶數格
const eSlots = evenByRow[r].filter(slot => !matrix[slot.r][slot.c]);
const eStudents = evenGroup2.slice(ei2, ei2 + eSlots.length);
shuffleArray(eStudents);
eStudents.forEach((s, i) => { matrix[eSlots[i].r][eSlots[i].c] = s; });
ei2 += eSlots.length;
// 奇數格
const oSlots = oddByRow[r].filter(slot => !matrix[slot.r][slot.c]);
const oStudents = oddGroup2.slice(oi2, oi2 + oSlots.length);
shuffleArray(oStudents);
oStudents.forEach((s, i) => { matrix[oSlots[i].r][oSlots[i].c] = s; });
oi2 += oSlots.length;
}
// 溢出補空格
const overflow = [...evenGroup2.slice(ei2), ...oddGroup2.slice(oi2)];
let ofi = 0;
for (let r = 0; r < rows && ofi < overflow.length; r++) {
for (let c = 0; c < cols && ofi < overflow.length; c++) {
if (!matrix[r][c]) matrix[r][c] = overflow[ofi++];
}
}
} else {
// ══════════════════════════════════════════════════
// 非棋盤式:固定前排優先放行1,其餘依序填入
// ══════════════════════════════════════════════════
if (useFixFront && fixFrontStudents.length > 0) {
// 固定前排填行1
let fi = 0;
for (let c = 0; c < cols && fi < fixFrontStudents.length; c++) {
if (!matrix[0][c]) matrix[0][c] = fixFrontStudents[fi++];
}
// 超出的加回 remaining
remaining = [...fixFrontStudents.slice(fi), ...remaining];
} else if (fixFrontStudents.length > 0) {
remaining = [...fixFrontStudents, ...remaining];
}
const emptySlots = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!matrix[r][c]) emptySlots.push({r, c});
}
}
for (let i = 0; i < Math.min(remaining.length, emptySlots.length); i++) {
matrix[emptySlots[i].r][emptySlots[i].c] = remaining[i];
}
}
// ── 不相鄰條件後處理 ──
if (arrangement.includes('noAdjacent')) {
applyNoAdjacentClass(matrix, rows, cols);
}
// ── 轉為前端格式,同時回傳違規清單 ──
const seats = [];
const violations = []; // 不相鄰違規配對
for (let r = 0; r < rows; r++) {
const rowData = [];
for (let c = 0; c < cols; c++) {
const s = matrix[r][c];
rowData.push(s ? {
id: s.id, name: s.name, gender: s.gender, height: s.height,
fixFront: s.fixFront, row: r, col: c
} : null);
}
seats.push(rowData);
}
// 掃描左右相鄰違規
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const a = matrix[r][c];
const b = matrix[r][c + 1];
if (!a || !b) continue;
const aNot = (a.noAdjacent || '').split(',').map(x => x.trim()).filter(Boolean);
const bNot = (b.noAdjacent || '').split(',').map(x => x.trim()).filter(Boolean);
if (aNot.includes(b.id) || bNot.includes(a.id)) {
violations.push(`${a.id}號與${b.id}號仍相鄰`);
}
}
}
return { seats, violations };
}
// ---- 6b. 小組座位 ----
function buildGroupSeats(pool, numGroups, arrangement, params) {
// ── 平均分組:人數均等 + 性別均等 ──
//
// 核心邏輯:
// 1. 先決定每組「名額上限」:base = floor(n/g),前 remainder 組多1名額
// 例:26人6組 → base=4, remainder=2 → 第1~2組5名額,第3~6組4名額
// 2. 男女各自按名額比例分配到各組(蛇形分配,讓每組男女盡量均等)
// 例:26人(男14女12)6組
// 每組名額:[5,5,4,4,4,4]
// 男14:每組男生名額 = floor(14*quota/26),用同比例分
// 女12:剩餘名額補女生
// 3. 結果:每組人數差≤1,每組性別差≤1
const total = pool.length;
const base = Math.floor(total / numGroups);
const remainder = total % numGroups;
// 每組名額陣列,前 remainder 組多1
const quotas = [];
for (let g = 0; g < numGroups; g++) {
quotas.push(g < remainder ? base + 1 : base);
}
// 男女分開,隨機或按身高
let males = pool.filter(s => s.gender === '男');
let females = pool.filter(s => s.gender === '女');
if (arrangement.includes('height')) {
males.sort((a, b) => a.height - b.height);
females.sort((a, b) => a.height - b.height);
} else {
shuffleArray(males);
shuffleArray(females);
}
const mTotal = males.length;
const fTotal = females.length;
// 計算每組應分到幾個男生(按名額比例,蛇形讓餘數均勻分散)
// maleQuota[g] = round(quotas[g] * mTotal / total),但總和要剛好等於 mTotal
const maleQuotas = quotas.map(q => Math.floor(q * mTotal / total));
let mAssigned = maleQuotas.reduce((a, b) => a + b, 0);
// 把餘數補到名額最大的組(依小數部分由大到小)
const mRemainders = quotas.map((q, i) => ({
i,
frac: (q * mTotal / total) - maleQuotas[i]
})).sort((a, b) => b.frac - a.frac);
for (let k = 0; k < mTotal - mAssigned; k++) {
maleQuotas[mRemainders[k].i]++;
}
// 女生名額 = 每組總名額 - 男生名額
const femaleQuotas = quotas.map((q, i) => q - maleQuotas[i]);
// 依名額填入各組
const groups = [];
let mi = 0, fi = 0;
for (let g = 0; g < numGroups; g++) {
const grp = [];
for (let k = 0; k < maleQuotas[g] && mi < mTotal; k++) grp.push(males[mi++]);
for (let k = 0; k < femaleQuotas[g] && fi < fTotal; k++) grp.push(females[fi++]);
groups.push(grp);
}
// 不同組條件:後處理
if (arrangement.includes('noSameGroup')) {
applyNoSameGroup(groups, pool);
}
return groups
.filter(g => g.length > 0)
.map((g, idx) => ({
groupIndex: idx + 1,
members: g.map(s => ({
id: s.id, name: s.name, gender: s.gender, height: s.height, fixFront: s.fixFront
}))
}));
}
// ============================================================
// 7. 輔助函數
// ============================================================
// 座位結果雜湊(用於比較兩次結果是否相同)
function hashSeats(seats) {
try {
if (Array.isArray(seats) && seats.length && seats[0].members !== undefined) {
// 小組模式
return seats.map(g => g.members.map(m => m.id).join(',')).join('|');
}
// 班級模式
return seats.map(row => row.map(s => s ? s.id : '0').join(',')).join('|');
} catch(e) { return ''; }
}
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function arrangeCheckerboard(pool) {
// 男女棋盤式:每格依 (row+col)%2 決定要放哪個性別
// 讓同一排的左右相鄰一定是不同性別
const males = shuffleArray(pool.filter(s => s.gender === '男'));
const females = shuffleArray(pool.filter(s => s.gender === '女'));
// 數量較多的性別放 (r+c)%2===0 的格子(共 ceil(30/2)=15 格)
const evenGender = males.length >= females.length ? males : females;
const oddGender = males.length >= females.length ? females : males;
const COLS = 6, ROWS = 5;
const result = [];
let ei = 0, oi = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
// (r+c) 偶數格 vs 奇數格交替,確保左右相鄰格一定不同性別
const wantEven = (r + c) % 2 === 0;
if (wantEven) {
if (ei < evenGender.length) result.push(evenGender[ei++]);
else if (oi < oddGender.length) result.push(oddGender[oi++]);
} else {
if (oi < oddGender.length) result.push(oddGender[oi++]);
else if (ei < evenGender.length) result.push(evenGender[ei++]);
}
}
}
return result;
}
function applyNoAdjacentClass(matrix, rows, cols) {
// 簡易交換:找出違規的左右相鄰對,嘗試隨機交換
const MAX_ITER = 200;
for (let iter = 0; iter < MAX_ITER; iter++) {
let violations = false;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols - 1; c++) {
const a = matrix[r][c];
const b = matrix[r][c + 1];
if (!a || !b) continue;
const aNot = (a.noAdjacent || '').split(',').map(x => x.trim()).filter(Boolean);
const bNot = (b.noAdjacent || '').split(',').map(x => x.trim()).filter(Boolean);
if (aNot.includes(b.id) || bNot.includes(a.id)) {
// 找一個可交換的位置
const rSwap = Math.floor(Math.random() * rows);
const cSwap = Math.floor(Math.random() * cols);
if (!(rSwap === r && Math.abs(cSwap - c) <= 1)) {
const temp = matrix[rSwap][cSwap];
matrix[rSwap][cSwap] = matrix[r][c];
matrix[r][c] = temp;
violations = true;
}
}
}
}
if (!violations) break;
}
}
function applyNoSameGroup(groups, pool) {
// 找出不同組條件的違規對,嘗試組間交換
const MAX_ITER = 100;
for (let iter = 0; iter < MAX_ITER; iter++) {
let violations = false;
for (let gi = 0; gi < groups.length; gi++) {
for (let si = 0; si < groups[gi].length; si++) {
const s = groups[gi][si];
const notSame = (s.noSameGroup || '').split(',').map(x => x.trim()).filter(Boolean);
for (const targetId of notSame) {
// 找 targetId 在哪組
for (let gj = 0; gj < groups.length; gj++) {
if (gj === gi) continue;
const idx = groups[gj].findIndex(x => x.id === targetId);
if (idx === -1) continue;
// 同組違規 -> 檢查 targetId 是否在同組
const sameGroup = groups[gi].some(x => x.id === targetId);
if (sameGroup) {
// 交換到其他組
const swapGi = (gi + 1) % groups.length;
if (groups[swapGi].length > 0) {
const swapIdx = Math.floor(Math.random() * groups[swapGi].length);
const temp = groups[swapGi][swapIdx];
groups[swapGi][swapIdx] = groups[gi][si];
groups[gi][si] = temp;
violations = true;
}
}
}
}
}
}
if (!violations) break;
}
}
// ============================================================
// 7b. 微調換位:相鄰行同性別身高差≤3cm 的學生互換
// ============================================================
function fineTuneSeats(seatsJson) {
try {
const seats = JSON.parse(seatsJson);
const ROWS = seats.length;
const COLS = seats[0] ? seats[0].length : 6;
const TOLERANCE = 3; // 身高容忍區間 cm
const MAX_ROUNDS = 50;
let changed = true;
let rounds = 0;
while (changed && rounds < MAX_ROUNDS) {
changed = false;
rounds++;
// 收集本輪所有可對調的配對(相鄰行、同性別、身高差≤3cm、非固定前排)
const pairs = [];
for (let r = 0; r < ROWS - 1; r++) {
for (let c1 = 0; c1 < COLS; c1++) {
const a = seats[r][c1];
if (!a || a.fixFront) continue;
for (let c2 = 0; c2 < COLS; c2++) {
const b = seats[r + 1][c2];
if (!b || b.fixFront) continue;
if (a.gender === b.gender && Math.abs(a.height - b.height) <= TOLERANCE) {
pairs.push({ r1: r, c1: c1, r2: r + 1, c2: c2 });
}
}
}
}
if (pairs.length === 0) break;
// 隨機打亂配對順序
for (let i = pairs.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pairs[i], pairs[j]] = [pairs[j], pairs[i]];
}
// 記錄本輪已被換過的格子,每格只換一次
const used = new Set();
pairs.forEach(p => {
const keyA = p.r1 + '-' + p.c1;
const keyB = p.r2 + '-' + p.c2;
if (used.has(keyA) || used.has(keyB)) return;
// 用暫存變數做真正的位置互換(避免 reference 共享)
const tmp = seats[p.r1][p.c1];
seats[p.r1][p.c1] = seats[p.r2][p.c2];
seats[p.r2][p.c2] = tmp;
used.add(keyA);
used.add(keyB);
changed = true;
});
}
return { success: true, seats: seats, rounds: rounds };
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 8. 歷史紀錄
// ============================================================
function saveHistory(params, seats) {
try {
const ss = getSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME_HISTORY);
if (!sheet) return;
const timestamp = Utilities.formatDate(new Date(), 'Asia/Taipei', 'yyyy-MM-dd HH:mm:ss');
const desc = `${params.mode === 'class' ? '班級座位' : '小組座位'} / ${(params.arrangement || []).join('+')}`;
const json = JSON.stringify(seats);
sheet.appendRow([timestamp, desc, json]);
// 只保留最近 20 筆
const lastRow = sheet.getLastRow();
if (lastRow > 21) {
sheet.deleteRows(2, lastRow - 21);
}
} catch(e) {
Logger.log('saveHistory error: ' + e.message);
}
}
function getHistory() {
try {
const ss = getSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME_HISTORY);
if (!sheet || sheet.getLastRow() < 2) return [];
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3).getValues();
return data.map((row, i) => ({
index: i,
timestamp: row[0],
desc: row[1],
seats: JSON.parse(row[2] || '[]')
})).reverse(); // 最新的在前
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 9. 匯出:將座位表寫入試算表並產生 PDF 下載連結
// ============================================================
function exportSeatsToPDF(seatsJson, params) {
try {
const ss = getSpreadsheet();
const sheetName = '座位表輸出';
let outSheet = ss.getSheetByName(sheetName);
if (outSheet) ss.deleteSheet(outSheet);
outSheet = ss.insertSheet(sheetName);
const seats = JSON.parse(seatsJson);
const mode = params.mode || 'class';
const maleColor = params.maleColor || '#bee3f8';
const femaleColor = params.femaleColor || '#fed7e2';
const showHeight = params.showHeight;
const boardPos = params.boardPosition || 'front';
outSheet.getRange(1,1).setValue('🏫 學生座位表').setFontSize(16).setFontWeight('bold');
const timeStr = Utilities.formatDate(new Date(), 'Asia/Taipei', 'yyyy-MM-dd HH:mm');
outSheet.getRange(1,4).setValue(timeStr);
let startRow = 3;
const boardLabel = boardPos === 'front' ? '⬛ 黑板(前方)' : '';
if (boardPos === 'front') {
outSheet.getRange(2, 1, 1, 6).merge().setValue(boardLabel)
.setHorizontalAlignment('center').setBackground('#4a5568').setFontColor('#fff').setFontWeight('bold');
}
if (mode === 'class') {
for (let r = 0; r < seats.length; r++) {
const row = seats[r];
for (let c = 0; c < row.length; c++) {
const cell = outSheet.getRange(startRow + r, c + 1);
const s = row[c];
if (s) {
const label = showHeight ? `${s.name}\n${s.id}\n${s.height}cm` : `${s.name}\n${s.id}`;
cell.setValue(label).setWrap(true)
.setBackground(s.gender === '男' ? maleColor : femaleColor)
.setHorizontalAlignment('center').setVerticalAlignment('middle')
.setBorder(true, true, true, true, true, true, '#555', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
} else {
cell.setValue('').setBackground('#f7fafc');
}
cell.setHeight && outSheet.setRowHeight(startRow + r, 60);
}
}
if (boardPos === 'back') {
const lastRow = startRow + seats.length;
outSheet.getRange(lastRow, 1, 1, 6).merge().setValue('⬛ 黑板(後方)')
.setHorizontalAlignment('center').setBackground('#4a5568').setFontColor('#fff').setFontWeight('bold');
}
} else {
// 小組模式
let col = 1;
seats.forEach((group, gi) => {
outSheet.getRange(startRow, col).setValue(`第 ${group.groupIndex} 組`)
.setFontWeight('bold').setBackground('#4a5568').setFontColor('#fff').setHorizontalAlignment('center');
group.members.forEach((s, si) => {
const cell = outSheet.getRange(startRow + 1 + si, col);
const label = showHeight ? `${s.name}\n${s.id}\n${s.height}cm` : `${s.name}\n${s.id}`;
cell.setValue(label).setWrap(true)
.setBackground(s.gender === '男' ? maleColor : femaleColor)
.setHorizontalAlignment('center').setVerticalAlignment('middle')
.setBorder(true, true, true, true, true, true, '#555', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
});
col++;
if (col > 6) { col = 1; startRow += 10; }
});
}
outSheet.setColumnWidths(1, 6, 100);
// 產生 PDF blob
const pdfBlob = ss.getAs('application/pdf');
pdfBlob.setName('座位表_' + timeStr.replace(/[: ]/g, '-') + '.pdf');
// 儲存到 Drive
// 儲存到試算表所在的資料夾
let savedFile;
try {
const ssFile = DriveApp.getFileById(getSpreadsheet().getId());
const parents = ssFile.getParents();
const folder = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
savedFile = folder.createFile(pdfBlob);
} catch(fe) {
savedFile = DriveApp.createFile(pdfBlob);
}
return { success: true, url: savedFile.getUrl(), name: savedFile.getName() };
} catch(e) {
return { error: e.message };
}
}
// ============================================================
// 10. 工具:取得試算表連結
// ============================================================
function getSpreadsheetUrl() {
return getSpreadsheet().getUrl();
} 貼到 Apps Script 的 Code.gs 檔案
5. 貼上前端(index.html)
把前端程式貼到 Apps Script 的 index.html。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>座位產生器</title>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">
<style>
:root{
--grass:#4caf50;--leaf:#2e7d32;--sun:#fffef5;--sun2:#ffd600;
--soil:#5d4037;
--male-bg:#dbeafe;--male-border:#3b82f6;--male-text:#1e3a8a;
--female-bg:#fce7f3;--female-border:#ec4899;--female-text:#831843;
--surface:#fff;--surface2:#f0f7ee;--border:#c8e6c9;--border2:#a5d6a7;
--text:#2d3a2e;--text-mid:#4a5a4b;--text-muted:#7a9a7b;
--radius:14px;--shadow-card:0 2px 12px rgba(60,120,40,0.10);
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Noto Sans TC',sans-serif;background:var(--sun);color:var(--text);min-height:100vh;}
.app{display:grid;grid-template-columns:380px 1fr;grid-template-rows:56px 1fr;height:100vh;transition:grid-template-columns 0.3s ease;}
.app.collapsed{grid-template-columns:0px 1fr;}
header{grid-column:1/-1;background:linear-gradient(135deg,#4caf50,#2e7d32);display:flex;align-items:center;gap:12px;padding:0 20px;box-shadow:0 3px 12px rgba(60,120,40,0.3);z-index:50;}
.logo{font-family:'Noto Sans TC',sans-serif;font-size:24px;font-weight:700;color:#fff;letter-spacing:2px;}
.pkbadge{background:rgba(255,255,255,0.2);color:#fff;font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;letter-spacing:1px;}
.hdr-right{margin-left:auto;display:flex;gap:8px;}
.sidebar{background:var(--surface);border-right:2px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:16px 14px;display:flex;flex-direction:column;gap:14px;transition:opacity 0.3s;height:100%;}
.app.collapsed .sidebar{opacity:0;pointer-events:none;}
.stoggle{position:fixed;left:380px;top:50vh;transform:translateY(-50%);z-index:200;background:#4caf50;color:#fff;border:none;border-radius:0 16px 16px 0;width:20px;height:52px;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;box-shadow:2px 0 10px rgba(60,120,40,0.3);transition:left 0.3s ease;font-weight:900;line-height:1;}
.app.collapsed .stoggle{left:0;}
.main{overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;min-width:0;height:100%;}
.slabel{font-size:11px;font-weight:700;color:#2e7d32;letter-spacing:2px;text-transform:uppercase;display:flex;align-items:center;gap:6px;margin-bottom:4px;}
.slabel::after{content:'';flex:1;height:1.5px;background:var(--border);}
.card{background:var(--surface2);border:1.5px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow-card);}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;font-family:inherit;cursor:pointer;border:2px solid transparent;transition:all 0.18s;white-space:nowrap;}
.btn-p{background:#4caf50;color:#fff;border-color:#2e7d32;box-shadow:0 2px 8px rgba(76,175,80,0.3);}
.btn-p:hover{background:#2e7d32;transform:translateY(-1px);}
.btn-s{background:#fff;color:#2e7d32;border-color:var(--border2);}
.btn-s:hover{background:var(--surface2);border-color:#4caf50;}
.btn-a{background:#ffd600;color:#5d4037;border-color:#f9a825;}
.btn-f{width:100%;}
.mode-row{display:flex;gap:8px;}
.mode-row .btn{flex:1;font-size:12px;}
.tab-bar{display:flex;background:#fff;border-radius:10px;padding:3px;gap:2px;border:1.5px solid var(--border);}
.tab-btn{flex:1;padding:7px 6px;border-radius:8px;border:none;background:transparent;font-size:12px;font-weight:700;font-family:inherit;color:var(--text-muted);cursor:pointer;transition:all 0.18s;}
.tab-btn.active{background:#4caf50;color:#fff;}
.fl{font-size:12px;color:var(--text-muted);margin-bottom:5px;display:block;font-weight:500;}
input[type=text],textarea,select{width:100%;background:#fff;border:1.5px solid var(--border);color:var(--text);border-radius:8px;padding:8px 10px;font-size:13px;font-family:inherit;outline:none;transition:border-color 0.2s;}
input[type=text]:focus,textarea:focus{border-color:#4caf50;}
textarea{resize:vertical;min-height:80px;}
.hint{font-size:10px;color:var(--text-muted);margin-top:3px;}
.trow{display:flex;align-items:center;justify-content:space-between;padding:6px 0;}
.tog{position:relative;width:40px;height:22px;flex-shrink:0;}
.tog input{opacity:0;width:0;height:0;}
.tslider{position:absolute;inset:0;background:#ccc;border-radius:22px;cursor:pointer;transition:0.3s;}
.tslider::before{content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:0.3s;}
.tog input:checked+.tslider{background:#4caf50;}
.tog input:checked+.tslider::before{transform:translateX(18px);}
.sli{display:flex;justify-content:space-between;align-items:center;padding:5px 8px;border-radius:8px;font-size:12px;}
.sli.male{background:var(--male-bg);}
.sli.female{background:var(--female-bg);}
.sw{background:#fff;border:1.5px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow-card);}
.shdr{padding:12px 16px;border-bottom:1.5px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.shdr h3{font-size:15px;font-weight:800;color:var(--soil);}
.blabel{text-align:center;padding:8px;background:linear-gradient(135deg,#5d4037,#4e342e);color:#fff;font-weight:700;font-size:12px;letter-spacing:4px;}
.sta{padding:12px 12px 0 12px;}
.sar{display:flex;gap:4px;}
.rl{display:flex;flex-direction:column;gap:7px;width:28px;min-width:28px;flex-shrink:0;}
.rlb{display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:900;color:var(--text-muted);background:var(--border);border-radius:6px;flex:1;min-height:50px;}
.sgw{flex:1;min-width:0;}
.sg{display:flex;flex-direction:column;gap:7px;}
.sr{display:flex;gap:7px;}
.sc{flex:1;min-width:0;border-radius:10px;padding:6px 8px;border:2px solid transparent;transition:transform 0.15s,box-shadow 0.15s;min-height:50px;display:flex;align-items:center;justify-content:center;position:relative;cursor:pointer;}
.sc:not(.empty):hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.12);z-index:2;}
.sc.male{background:var(--male-bg);border-color:var(--male-border);}
.sc.female{background:var(--female-bg);border-color:var(--female-border);}
.sc.empty{background:#f5f5f5;border:2px dashed #ddd;cursor:default;}
.sc.selected{outline:3px solid #ffd600;outline-offset:1px;transform:scale(1.05)!important;z-index:5;}
.si{display:flex;align-items:center;justify-content:center;gap:4px;width:100%;white-space:nowrap;overflow:hidden;}
.sno{font-size:11px;font-weight:900;flex-shrink:0;}
.sc.male .sno{color:var(--male-text);}
.sc.female .sno{color:var(--female-text);}
.sn{font-size:15px;font-weight:900;flex-shrink:1;overflow:hidden;text-overflow:ellipsis;}
.sc.male .sn{color:var(--male-text);}
.sc.female .sn{color:var(--female-text);}
.sh{font-size:11px;font-weight:600;flex-shrink:0;opacity:0.75;}
.sc.male .sh{color:var(--male-text);}
.sc.female .sh{color:var(--female-text);}
.spin{position:absolute;top:2px;right:3px;font-size:9px;}
.clr{display:flex;gap:7px;padding:4px 0 8px 32px;}
.cl{flex:1;text-align:center;font-size:9px;font-weight:900;color:var(--text-muted);}
.sg.teacher .sr{flex-direction:row-reverse;}
.gg{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:14px;}
.gc{background:var(--surface2);border:1.5px solid var(--border);border-radius:var(--radius);overflow:hidden;}
.gt{background:linear-gradient(135deg,#4caf50,#2e7d32);color:#fff;font-size:12px;font-weight:800;padding:7px 10px;text-align:center;letter-spacing:2px;}
.gm-list{padding:6px;display:flex;flex-direction:column;gap:4px;}
.gm{border-radius:8px;padding:6px 10px;font-size:12px;display:flex;justify-content:space-between;align-items:center;border:1.5px solid transparent;}
.gm.male{background:var(--male-bg);border-color:var(--male-border);}
.gm.female{background:var(--female-bg);border-color:var(--female-border);}
.gm.male .gmn{color:var(--male-text);font-weight:700;}
.gm.female .gmn{color:var(--female-text);font-weight:700;}
.gmi{font-size:10px;color:var(--text-muted);}
.leg{display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
.lgi{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-muted);}
.lgd{width:12px;height:12px;border-radius:3px;border:2px solid transparent;}
.lgd.male{background:var(--male-bg);border-color:var(--male-border);}
.lgd.female{background:var(--female-bg);border-color:var(--female-border);}
.hp{background:#fff;border:1.5px solid var(--border);border-radius:var(--radius);overflow:hidden;}
.hhdr{padding:12px 16px;display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;font-weight:700;font-size:13px;}
.hhdr:hover{background:var(--surface2);}
.hbody{display:none;max-height:260px;overflow-y:auto;padding:8px;}
.hbody.open{display:block;}
.hi{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:12px;cursor:pointer;border:1.5px solid transparent;transition:all 0.18s;}
.hi:hover{background:var(--surface2);border-color:var(--border);}
.ht{color:var(--text-muted);font-size:10px;flex-shrink:0;}
.hdesc{flex:1;}
.hrs{color:#4caf50;font-weight:700;font-size:11px;}
.es{text-align:center;padding:60px 20px;color:var(--text-muted);}
.es .icon{font-size:56px;margin-bottom:12px;}
.es h3{font-size:18px;font-weight:800;color:var(--text-mid);margin-bottom:6px;}
.lov{position:fixed;inset:0;background:rgba(255,254,245,0.88);backdrop-filter:blur(4px);z-index:1000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;opacity:0;pointer-events:none;transition:opacity 0.25s;}
.lov.show{opacity:1;pointer-events:all;}
.pldr{font-size:52px;animation:pks 1s linear infinite;}
@keyframes pks{to{transform:rotate(360deg);}}
.lt{font-size:14px;color:var(--text-mid);font-weight:600;}
.tc{position:fixed;bottom:20px;right:20px;display:flex;flex-direction:column;gap:7px;z-index:2000;}
.t{background:#fff;border:1.5px solid var(--border);border-radius:10px;padding:10px 16px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,0.12);display:flex;align-items:center;gap:8px;animation:tsi 0.25s ease;max-width:300px;}
.t.success{border-left:4px solid #4caf50;}
.t.error{border-left:4px solid #e53935;}
@keyframes tsi{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}
.pkd{position:fixed;font-size:24px;pointer-events:none;z-index:0;animation:pkf 4s ease-in-out infinite;opacity:0.4;}
@keyframes pkf{0%,100%{transform:translateY(0) rotate(-5deg);}50%{transform:translateY(-12px) rotate(5deg);}}
@media(max-width:600px){
/* 手機版:座位格只顯示座號,隱藏姓名和身高 */
.sn{ display:none; }
.sh{ display:none; }
.sc{ min-height:42px; padding:4px 3px; }
.sno{ font-size:14px; font-weight:900; }
.sr{ gap:4px; }
.sg{ gap:4px; }
.rl{ width:22px; min-width:22px; }
.rlb{ font-size:8px; min-height:42px; }
.cl{ font-size:8px; }
}
@media(max-width:900px){
.app{grid-template-columns:1fr!important;grid-template-rows:56px auto 1fr;}
.sidebar{max-height:50vh;border-right:none;border-bottom:2px solid var(--border);}
.stoggle{display:none;}
.gg{grid-template-columns:repeat(2,1fr);}
}
.chk-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer;padding:4px 2px;}
.chk-row input[type=checkbox]{accent-color:var(--grass);width:15px;height:15px;cursor:pointer;flex-shrink:0;}
</style>
</head>
<body>
<div class="pkd" style="bottom:60px;left:20px;animation-delay:0s;">🌱</div>
<div class="pkd" style="bottom:40px;right:60px;animation-delay:1.5s;">🍄</div>
<div class="pkd" style="bottom:80px;left:45%;animation-delay:0.8s;">🌼</div>
<div class="app" id="app">
<header>
<div class="logo">🌿 座位產生器</div>
<span class="pkbadge">PIKMIN</span>
<div class="hdr-right">
<button class="btn btn-s" style="font-size:12px;" onclick="openSpreadsheet()">📊 試算表</button>
<button class="btn btn-s" style="font-size:12px;" onclick="reloadStudents()">🔄 重新讀取</button>
</div>
</header>
<button class="stoggle" id="stoggle" onclick="toggleSidebar()">◀</button>
<aside class="sidebar" id="sidebar">
<div>
<div class="slabel">🪑 座位模式</div>
<div class="mode-row">
<button class="btn btn-p" id="btnClass" onclick="setMode('class')">🏛 班級座位</button>
<button class="btn btn-s" id="btnGroup" onclick="setMode('group')">👥 小組座位</button>
</div>
</div>
<div>
<div class="slabel">🌿 學生資料 (<span id="studentCount">0</span>人)</div>
<div class="card">
<div class="tab-bar">
<button class="tab-btn active" id="tabBtnPaste" onclick="switchTab('paste')">📋 貼上輸入</button>
<button class="tab-btn" id="tabBtnList" onclick="switchTab('list')">👀 已載入名單</button>
</div>
<div style="margin-top:8px;">
<div id="tab-paste" style="display:block;">
<label class="fl">座號 姓名 性別 身高(從試算表複製貼上)</label>
<textarea id="pasteArea" rows="5" placeholder="貼上後點確認匯入 例:1	王小明	男	172"></textarea>
<button class="btn btn-p btn-f" style="margin-top:6px;font-size:12px;" onclick="parseStudentPaste()">✅ 確認匯入</button>
</div>
<div id="tab-list" style="display:none;">
<div id="studentList" style="max-height:160px;overflow-y:auto;display:flex;flex-direction:column;gap:3px;">
<div style="color:var(--text-muted);font-size:12px;">尚未載入學生資料</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="slabel">⚙️ 基本設定</div>
<div class="card" style="display:flex;flex-direction:column;gap:10px;">
<div>
<label class="fl">👁 座位呈現視角</label>
<div style="display:flex;gap:6px;">
<button class="btn btn-p" id="btnStudent" style="flex:1;font-size:11px;" onclick="setViewAngle('student')">🧒 學生視角</button>
<button class="btn btn-s" id="btnTeacher" style="flex:1;font-size:11px;" onclick="setViewAngle('teacher')">👩🏫 老師視角</button>
</div>
<div class="hint">黑板固定在前。老師視角左右對調。</div>
</div>
<div class="trow">
<label class="fl" style="margin:0;">顯示身高</label>
<label class="tog"><input type="checkbox" id="showHeight" checked><span class="tslider"></span></label>
</div>
<div style="display:flex;gap:8px;">
<div style="flex:1;"><label class="fl">男生顏色</label><input type="color" id="maleColor" value="#dbeafe" style="width:100%;height:34px;padding:2px 4px;border:1.5px solid var(--border);border-radius:8px;cursor:pointer;"></div>
<div style="flex:1;"><label class="fl">女生顏色</label><input type="color" id="femaleColor" value="#fce7f3" style="width:100%;height:34px;padding:2px 4px;border:1.5px solid var(--border);border-radius:8px;cursor:pointer;"></div>
</div>
</div>
</div>
<div>
<div class="slabel">🎲 排列方式 <span style="font-size:9px;letter-spacing:0;text-transform:none;color:var(--text-muted);">勾選啟用</span></div>
<div class="card" style="display:flex;flex-direction:column;gap:8px;">
<label class="chk-row"><input type="checkbox" id="chk_random" checked>🎲 隨機排列</label>
<label class="chk-row"><input type="checkbox" id="chk_height">📏 按身高(前矮後高)</label>
<label class="chk-row"><input type="checkbox" id="chk_checkerboard">♟ 男女棋盤式</label>
<div>
<label class="chk-row"><input type="checkbox" id="chk_fixFront" onchange="toggleCondInput('fixFront',this.checked)">📌 固定前排</label>
<div id="inp_fixFront" style="display:none;padding:5px 0 2px 22px;">
<input type="text" id="val_fixFront" placeholder="座號,逗號分隔,例:1,5,12" style="font-size:11px;width:100%;">
</div>
</div>
<div>
<label class="chk-row"><input type="checkbox" id="chk_noAdjacent" onchange="toggleCondInput('noAdjacent',this.checked)">🚫 不相鄰條件</label>
<div id="inp_noAdjacent" style="display:none;padding:5px 0 2px 22px;">
<input type="text" id="val_noAdjacent" placeholder="座號-座號,逗號分隔,例:1-5,3-8" style="font-size:11px;width:100%;">
</div>
</div>
<div>
<label class="chk-row"><input type="checkbox" id="chk_noSameGroup" onchange="toggleCondInput('noSameGroup',this.checked)">🔀 不同組條件</label>
<div id="inp_noSameGroup" style="display:none;padding:5px 0 2px 22px;">
<input type="text" id="val_noSameGroup" placeholder="座號-座號,逗號分隔,例:1-5,3-8" style="font-size:11px;width:100%;">
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:7px;">
<button class="btn btn-p btn-f" onclick="generateSeats()">🌱 產生座位</button>
<button class="btn btn-s btn-f" id="btnFineTune" onclick="fineTuneSeats()" style="border-color:#ff9800;color:#e65100;" disabled>🔀 微調換位</button>
<button class="btn btn-a btn-f" onclick="exportPDF()">📄 匯出 PDF</button>
</div>
</aside>
<main class="main">
<div class="es" id="emptyState">
<div class="icon">🌿</div>
<h3>尚未產生座位</h3>
<p>設定好條件,點「產生座位」開始!</p>
</div>
<div id="seatWrapper" style="display:none;">
<div class="sw">
<div class="shdr">
<h3 id="seatTitle">班級座位表</h3>
<div class="leg">
<div class="lgi"><div class="lgd male"></div>男生</div>
<div class="lgi"><div class="lgd female"></div>女生</div>
</div>
<div style="margin-left:auto;font-size:11px;color:var(--text-muted);" id="seatTs"></div>
</div>
<div id="seatContent"></div>
</div>
</div>
</main>
</div>
<div class="lov" id="loadingOverlay">
<div class="pldr">🌱</div>
<div class="lt" id="loadingText">處理中…</div>
</div>
<div class="tc" id="toastContainer"></div>
<script>
var APP = { mode:'class', viewAngle:'student', students:[], currentSeats:null, ssUrl:'', sel:null, lastHash:'' };
window.onload = function() {
initColors();
initChkChange();
setMode('class');
setViewAngle('student');
loadData();
};
/* ── 收合 ── */
function toggleSidebar() {
var app = document.getElementById('app');
var btn = document.getElementById('stoggle');
var c = app.classList.toggle('collapsed');
btn.textContent = c ? '▶' : '◀';
}
/* ── 模式 ── */
function setMode(m) {
APP.mode = m;
document.getElementById('btnClass').className = m==='class' ? 'btn btn-p' : 'btn btn-s';
document.getElementById('btnGroup').className = m==='group' ? 'btn btn-p' : 'btn btn-s';
}
/* ── Tab ── */
function switchTab(t) {
document.getElementById('tabBtnPaste').className = t==='paste' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabBtnList').className = t==='list' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tab-paste').style.display = t==='paste' ? 'block' : 'none';
document.getElementById('tab-list').style.display = t==='list' ? 'block' : 'none';
}
/* ── 視角 ── */
function setViewAngle(a) {
APP.viewAngle = a;
var bs = document.getElementById('btnStudent');
var bt = document.getElementById('btnTeacher');
bs.className = a==='student' ? 'btn btn-p' : 'btn btn-s';
bs.style.cssText = 'flex:1;font-size:11px;';
bt.className = a==='teacher' ? 'btn btn-p' : 'btn btn-s';
bt.style.cssText = 'flex:1;font-size:11px;';
if (APP.currentSeats && APP.currentSeats.mode==='class') {
var showH = document.getElementById('showHeight').checked;
document.getElementById('seatContent').innerHTML = mkClass(APP.currentSeats.seats, showH, a);
}
}
/* ── 顏色 ── */
function initColors() {
document.getElementById('maleColor').addEventListener('input', function(e){
document.documentElement.style.setProperty('--male-bg', e.target.value);
});
document.getElementById('femaleColor').addEventListener('input', function(e){
document.documentElement.style.setProperty('--female-bg', e.target.value);
});
}
/* ── 排列互斥 ── */
function initChkChange() {
// 互斥規則:
// 隨機 vs 按身高(兩者排序邏輯衝突,只能擇一)
// 棋盤式可以和隨機或身高一起勾選(棋盤只管性別位置,隨機/身高管順序)
document.getElementById('chk_height').addEventListener('change', function(){
if (this.checked) document.getElementById('chk_random').checked = false;
});
document.getElementById('chk_random').addEventListener('change', function(){
if (this.checked) document.getElementById('chk_height').checked = false;
});
}
function getArr() {
var keys = ['random','height','checkerboard','fixFront','noAdjacent','noSameGroup'];
var a = keys.filter(function(k){
var c = document.getElementById('chk_' + k);
return c && c.checked;
});
return a.length ? a : ['random'];
}
/* ── 拖曳 ── */
/* ── 貼上匯入 ── */
function parseStudentPaste() {
var raw = document.getElementById('pasteArea').value.trim();
if (!raw) { toast('請先貼上資料','error'); return; }
var students=[];
raw.split('\n').forEach(function(line){
var cols = line.trim().split(/\t|,|,/);
if (cols.length >= 3) {
var id=cols[0].trim(), name=cols[1].trim(), gender=cols[2].trim(), height=parseFloat(cols[3])||160;
if (id && name) students.push({id:id,name:name,gender:gender,height:height,fixFront:false,fixSeat:'',noAdjacent:'',noSameGroup:''});
}
});
if (!students.length) { toast('格式錯誤,請確認欄位順序','error'); return; }
APP.students = students;
renderList();
switchTab('list');
toast('已匯入 '+students.length+' 位學生 🌱','success');
}
/* ── 套用條件 ── */
function find(id) {
return APP.students.find(function(x){ return String(x.id) === String(id); });
}
function applyConditions() {
if (!APP.students.length) return;
APP.students.forEach(function(s){ s.fixFront=false; s.fixSeat=''; s.noAdjacent=''; s.noSameGroup=''; });
var ff=document.getElementById('val_fixFront');
if(ff&&ff.value.trim()) ff.value.trim().split(',').forEach(function(id){ var s=find(id.trim()); if(s) s.fixFront=true; });
var fs=document.getElementById('fixSeatInput');
if(fs&&fs.value.trim()) fs.value.trim().split(',').forEach(function(pair){
var p=pair.trim().split(':'); if(p.length===2){ var s=find(p[0].trim()); if(s) s.fixSeat=p[1].trim().replace('-',','); }
});
var na=document.getElementById('val_noAdjacent');
if(na&&na.value.trim()) na.value.trim().split(',').forEach(function(pair){
var p=pair.trim().split('-'); if(p.length===2){
var sa=find(p[0].trim()),sb=find(p[1].trim());
if(sa) sa.noAdjacent=(sa.noAdjacent?sa.noAdjacent+',':'')+p[1].trim();
if(sb) sb.noAdjacent=(sb.noAdjacent?sb.noAdjacent+',':'')+p[0].trim();
}
});
var ng=document.getElementById('val_noSameGroup');
if(ng&&ng.value.trim()) ng.value.trim().split(',').forEach(function(pair){
var p=pair.trim().split('-'); if(p.length===2){
var sa=find(p[0].trim()),sb=find(p[1].trim());
if(sa) sa.noSameGroup=(sa.noSameGroup?sa.noSameGroup+',':'')+p[1].trim();
if(sb) sb.noSameGroup=(sb.noSameGroup?sb.noSameGroup+',':'')+p[0].trim();
}
});
}
/* ── 讀取資料 ── */
function loadData() {
loading('讀取資料…');
Promise.all([callGS('getStudents'), callGS('getSpreadsheetUrl')])
.then(function(r){
hideLoad();
if (r[0]&&!r[0].error&&r[0].length){ APP.students=r[0]; renderList(); }
if (r[1]) APP.ssUrl=r[1];
}).catch(function(e){ hideLoad(); toast('讀取失敗:'+e,'error'); });
}
function reloadStudents() {
loading('重新讀取…');
callGS('getStudents').then(function(r){
hideLoad();
if (r&&!r.error){ APP.students=r; renderList(); toast('已載入'+r.length+'位學生','success'); }
else toast('讀取失敗','error');
});
}
function openSpreadsheet() {
if (APP.ssUrl) window.open(APP.ssUrl,'_blank');
else callGS('getSpreadsheetUrl').then(function(u){ if(u) window.open(u,'_blank'); });
}
/* ── 產生座位 ── */
function generateSeats() {
if (!APP.students.length) { toast('請先匯入學生資料','error'); return; }
// 先套用條件輸入欄的值
applyConditions();
var params = {
mode: APP.mode,
arrangement: getArr(),
showHeight: document.getElementById('showHeight').checked,
rows: 5, cols: 6, groups: 6,
prevHash: APP.lastHash || '' // 傳入上次結果 hash,確保不重複
};
loading('產生座位中… 🌱');
callGS('generateSeats', params, JSON.stringify(APP.students)).then(function(r){
hideLoad();
if (!r||r.error){ toast('錯誤:'+(r?r.error:'未知'),'error'); return; }
APP.currentSeats = r;
APP.lastHash = r.hash || '';
renderSeats(r);
if (r.violations && r.violations.length > 0) {
setTimeout(function(){
toast('⚠️ 不相鄰條件未完全滿足:' + r.violations.join('、'), 'error');
}, 600);
} else {
toast('座位產生完成 🌼', 'success');
}
}).catch(function(e){ hideLoad(); toast('錯誤:'+e,'error'); });
}
/* ── 渲染 ── */
function renderSeats(r) {
APP.sel=null;
document.getElementById('emptyState').style.display='none';
document.getElementById('seatWrapper').style.display='block';
// 班級座位才啟用微調換位
var ftBtn = document.getElementById('btnFineTune');
if (ftBtn) ftBtn.disabled = (r.mode !== 'class');
var showH=document.getElementById('showHeight').checked;
if (r.mode==='class') {
document.getElementById('seatTitle').textContent='🏛 班級座位表(6×5)';
document.getElementById('seatContent').innerHTML=mkClass(r.seats,showH,APP.viewAngle);
} else {
document.getElementById('seatTitle').textContent='👥 小組座位表';
document.getElementById('seatContent').innerHTML=mkGroup(r.seats,showH);
}
document.getElementById('seatTs').textContent=new Date().toLocaleTimeString('zh-TW');
}
function mkClass(seats,showH,va) {
var teacher=(va==='teacher');
var ROWS=seats.length, COLS=seats[0]?seats[0].length:6;
var rl='';
for (var r=0;r<ROWS;r++) rl+='<div class="rlb">行'+(r+1)+'</div>';
var cl='';
for (var c=1;c<=COLS;c++) cl+='<div class="cl">列'+(teacher?COLS-c+1:c)+'</div>';
var grid='<div class="sg'+(teacher?' teacher':'')+'">'+seats.map(function(row,ri){
return '<div class="sr">'+row.map(function(s,ci){
var pos=ri+'-'+ci;
if (!s) return '<div class="sc empty" data-pos="'+pos+'" onclick="selSeat(this)"><span style="font-size:10px;color:#bbb;">空</span></div>';
var gc=s.gender==='男'?'male':'female';
return '<div class="sc '+gc+'" data-pos="'+pos+'" onclick="selSeat(this)">'
+'<div class="si">'
+'<span class="sno">'+s.id+'</span>'
+'<span class="sn">'+s.name+'</span>'
+(showH?'<span class="sh">'+Math.floor(s.height)+'</span>':'')
+'</div>'
+(s.fixFront?'<span class="spin">📌</span>':'')
+'</div>';
}).join('')+'</div>';
}).join('')+'</div>';
return '<div class="blabel">🟫 黑 板(前方)</div>'
+'<div class="sta"><div class="sar"><div class="rl">'+rl+'</div><div class="sgw">'+grid+'</div></div>'
+'<div class="clr">'+cl+'</div></div>';
}
function mkGroup(groups,showH) {
return '<div class="gg">'+groups.map(function(g){
return '<div class="gc"><div class="gt">第 '+g.groupIndex+' 組</div><div class="gm-list">'
+g.members.map(function(m){
var gc=m.gender==='男'?'male':'female';
return '<div class="gm '+gc+'"><span class="gmn">'+m.name+'</span><span class="gmi">'+m.id+(showH?' · '+Math.floor(m.height):'')+'</span></div>';
}).join('')+'</div></div>';
}).join('')+'</div>';
}
/* ── 條件勾選展開 ── */
function toggleCondInput(key, show) {
var el = document.getElementById('inp_' + key);
if (el) el.style.display = show ? 'block' : 'none';
if (!show) {
var inp = document.getElementById('val_' + key);
if (inp) inp.value = '';
}
}
/* ── 座位互換 ── */
function selSeat(el) {
if (!APP.currentSeats||APP.currentSeats.mode!=='class') return;
if (!APP.sel) {
APP.sel=el; el.classList.add('selected');
toast('已選取!再點另一格互換 🌿','success');
} else {
if (APP.sel===el) { el.classList.remove('selected'); APP.sel=null; return; }
var a=APP.sel.dataset.pos.split('-').map(Number);
var b=el.dataset.pos.split('-').map(Number);
var seats=APP.currentSeats.seats;
var tmp=seats[a[0]][a[1]]; seats[a[0]][a[1]]=seats[b[0]][b[1]]; seats[b[0]][b[1]]=tmp;
APP.sel.classList.remove('selected'); APP.sel=null;
document.getElementById('seatContent').innerHTML=mkClass(seats,document.getElementById('showHeight').checked,APP.viewAngle);
toast('互換完成 🌼','success');
}
}
/* ── 學生列表 ── */
function renderList() {
document.getElementById('studentCount').textContent=APP.students.length;
var el=document.getElementById('studentList');
el.innerHTML=APP.students.map(function(s){
return '<div class="sli '+(s.gender==='男'?'male':'female')+'"><span><b>'+s.id+'</b> '+s.name+'</span><span style="color:var(--text-muted);">'+s.height+'</span></div>';
}).join('');
}
/* ── 歷史 ── */
/* ── 微調換位 ── */
function fineTuneSeats() {
if (!APP.currentSeats || APP.currentSeats.mode !== 'class') {
toast('請先產生班級座位', 'error'); return;
}
loading('微調換位中… 🔀');
callGS('fineTuneSeats', JSON.stringify(APP.currentSeats.seats)).then(function(r) {
hideLoad();
if (!r || r.error) { toast('錯誤:' + (r ? r.error : '未知'), 'error'); return; }
APP.currentSeats.seats = r.seats;
var showH = document.getElementById('showHeight').checked;
document.getElementById('seatContent').innerHTML = mkClass(r.seats, showH, APP.viewAngle);
document.getElementById('seatTs').textContent = new Date().toLocaleTimeString('zh-TW');
toast('微調完成,共執行 ' + r.rounds + ' 輪 🌼', 'success');
}).catch(function(e) { hideLoad(); toast('錯誤:' + e, 'error'); });
}
/* ── PDF 匯出(前端列印,不需要試算表)── */
function exportPDF() {
if (!APP.currentSeats || APP.currentSeats.mode !== 'class') {
toast('請先產生班級座位表', 'error'); return;
}
var seats = APP.currentSeats.seats;
var maleColor = document.getElementById('maleColor').value || '#dbeafe';
var femaleColor = document.getElementById('femaleColor').value || '#fce7f3';
// 產生列印視窗 HTML
var studentView = buildPrintGrid(seats, 'student', maleColor, femaleColor);
var teacherView = buildPrintGrid(seats, 'teacher', maleColor, femaleColor);
var html = '<!DOCTYPE html><html lang="zh-TW"><head><meta charset="UTF-8">'
+ '<title>座位表</title><style>'
+ '@page{size:A4 portrait;margin:12mm;}'
+ '*{box-sizing:border-box;margin:0;padding:0;font-family:"Noto Sans TC",sans-serif;}'
+ 'body{background:#fff;color:#000;}'
+ '-webkit-print-color-adjust:exact;'
+ 'print-color-adjust:exact;'
+ '.page{width:100%;}'
+ '.view-block{width:100%;margin-bottom:8mm;}'
+ '.view-title{font-size:11px;font-weight:900;text-align:center;letter-spacing:3px;color:#444;margin-bottom:3mm;}'
+ '.board-bar{background:#3e2723;color:#fff;text-align:center;font-size:10px;font-weight:700;padding:4px;letter-spacing:4px;margin-bottom:2mm;}'
+ '.seat-grid{display:flex;flex-direction:column;gap:3px;}'
+ '.seat-row{display:flex;gap:3px;}'
+ '.seat-cell{flex:1;border-radius:6px;padding:4px 3px;border:2px solid transparent;min-height:38px;display:flex;align-items:center;justify-content:center;text-align:center;font-size:11px;font-weight:700;}'
+ '.seat-cell.male{background:' + maleColor + ';border-color:#3b82f6;border-width:2.5px;}'
+ '.seat-cell.female{background:' + femaleColor + ';border-color:#ec4899;border-width:1.5px;border-style:dashed;}'
+ '.seat-cell.empty{background:#f5f5f5;border:1px dashed #ccc;color:#bbb;font-size:9px;}'
+ '.seat-no{font-size:9px;margin-right:2px;}'
+ '.seat-name{font-size:12px;}'
+ '.col-labels{display:flex;gap:3px;margin-top:2px;}'
+ '.col-label{flex:1;text-align:center;font-size:8px;color:#888;}'
+ '.row-col-wrap{display:flex;gap:2px;}'
+ '.row-labels{display:flex;flex-direction:column;gap:3px;width:18px;flex-shrink:0;}'
+ '.row-label{display:flex;align-items:center;justify-content:center;font-size:8px;color:#888;min-height:38px;}'
+ '.divider{border:none;border-top:1.5px dashed #bbb;margin:4mm 0;}'
+ '@media print{'
+ '-webkit-print-color-adjust:exact!important;'
+ 'print-color-adjust:exact!important;'
+ '.seat-cell.male{background:' + maleColor + '!important;}'
+ '.seat-cell.female{background:' + femaleColor + '!important;}'
+ '}'
+ '</style></head><body>'
+ '<div class="page">'
+ '<div class="view-block">'
+ '<div class="view-title">▲ 學生視角</div>'
+ studentView
+ '</div>'
+ '<hr class="divider">'
+ '<div class="view-block">'
+ '<div class="view-title">▼ 老師視角</div>'
+ teacherView
+ '</div>'
+ '</div>'
+ '<scr'+'ipt>window.onload=function(){window.print();}<'+'/scr'+'ipt>'
+ '</body></html>';
var w = window.open('', '_blank');
w.document.write(html);
w.document.close();
}
function buildPrintGrid(seats, viewAngle, maleColor, femaleColor) {
var isTeacher = (viewAngle === 'teacher');
var ROWS = seats.length;
var COLS = seats[0] ? seats[0].length : 6;
var rowLabels = '';
for (var r = 0; r < ROWS; r++) {
rowLabels += '<div class="row-label">行' + (r+1) + '</div>';
}
var colLabels = '';
for (var c = 1; c <= COLS; c++) {
colLabels += '<div class="col-label">列' + (isTeacher ? COLS-c+1 : c) + '</div>';
}
var grid = '<div class="seat-grid">';
for (var ri = 0; ri < ROWS; ri++) {
grid += '<div class="seat-row">';
var row = isTeacher ? seats[ri].slice().reverse() : seats[ri];
for (var ci = 0; ci < row.length; ci++) {
var s = row[ci];
if (!s) {
grid += '<div class="seat-cell empty">空</div>';
} else {
var gc = s.gender === '男' ? 'male' : 'female';
grid += '<div class="seat-cell ' + gc + '">'
+ '<span class="seat-no">' + s.id + '</span>'
+ '<span class="seat-name">' + s.name + '</span>'
+ '</div>';
}
}
grid += '</div>';
}
grid += '</div>';
return '<div class="board-bar">🟫 黑 板(前方)</div>'
+ '<div class="row-col-wrap">'
+ '<div class="row-labels">' + rowLabels + '</div>'
+ '<div style="flex:1;">' + grid
+ '<div class="col-labels" style="padding-left:0;">' + colLabels + '</div>'
+ '</div></div>';
}
/* ── GAS 呼叫 ── */
function callGS(fn) {
var args=Array.prototype.slice.call(arguments,1);
return new Promise(function(resolve,reject){
if (typeof google!=='undefined'&&google.script&&google.script.run) {
var r=google.script.run.withSuccessHandler(resolve).withFailureHandler(reject);
r[fn].apply(r,args);
} else {
setTimeout(function(){ resolve(mockData(fn)); },300);
}
});
}
function mockData(fn) {
if (fn==='getStudents') {
var d=[['王明','男',172],['李華','女',158],['張美','女',161],['陳偉','男',180],['林玲','女',155],['黃志','男',174],['劉珍','女',163],['吳宏','男',168],['蔡婷','女',159],['鄭豪','男',177],['許芬','女',156],['謝明','男',183],['周雯','女',165],['江志','男',170],['方淑','女',160],['盧建','男',175],['潘麗','女',158],['郭翔','男',178],['范惠','女',164],['洪彬','男',171],['沈琪','女',166],['葉豪','男',176],['曾芳','女',157],['楊棟','男',182],['賴惠','女',162],['邱遠','男',169]];
return d.map(function(n,i){ return {id:String(i+1),name:n[0],gender:n[1],height:n[2],fixFront:false,fixSeat:'',noAdjacent:'',noSameGroup:''}; });
}
if (fn==='getSpreadsheetUrl') return '#';
if (fn==='generateSeats') {
var sts=mockData('getStudents'),seats=[],si=0;
for(var r=0;r<5;r++){var row=[];for(var c=0;c<6;c++) row.push(si<sts.length?Object.assign({},sts[si++]):null);seats.push(row);}
return {success:true,seats:seats,mode:'class'};
}
return null;
}
/* ── UI ── */
function loading(t){ document.getElementById('loadingText').textContent=t||'處理中…'; document.getElementById('loadingOverlay').classList.add('show'); }
function hideLoad(){ document.getElementById('loadingOverlay').classList.remove('show'); }
function toast(msg,type) {
var c=document.getElementById('toastContainer');
var el=document.createElement('div');
el.className='t '+(type||'success');
el.textContent=(type==='error'?'❌ ':'✅ ')+msg;
c.appendChild(el);
setTimeout(function(){ el.style.transition='0.3s'; el.style.opacity='0'; el.style.transform='translateX(100%)'; setTimeout(function(){ el.remove(); },300); },3000);
}
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案
6. 部署
部署成 Web App 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論