0. 作品介紹

作者 楊世傑 西門國小

試算表「座號、姓名、性別、身高」資料複製貼上一鍵匯入,可客製化一般座位產生器:依(1)隨機、(2)身高、(3)男女棋盤式、(4)固定前排、(4)座位不相鄰、(5)不同組 來排列,生成結果仍可手動點選兩座位微調,完成後一鍵輸出學生視角及老師視角列印


125 0 0
班級經營 非教學
封面圖 封面

2. 資料表規則

請照下面規則建立分頁與欄位。

資料表規則圖
第1分頁: 分頁1 A 欄: 座號 B 欄: 姓名 C 欄: 性別 D 欄: 身高

📄 解析結果(自動表格)

分頁:分頁1
A 欄 B 欄 C 欄 D 欄
座號 姓名 性別 身高

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

  1. 試算表上方選單:擴充功能 → Apps Script
  2. 刪掉預設的程式碼(或全部選取覆蓋)
  3. 保留/建立檔案:
    • Code.gs
    • index.html

4. 貼上後端(Code.gs)

把後端程式貼到 Apps Script 的 Code.gs。

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。

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="貼上後點確認匯入&#10;例:1&#9;王小明&#9;男&#9;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 後,就會拿到可分享網址。

  1. Apps Script 右上:部署 → 新部署
  2. 類型選:Web app
  3. 執行身分:你
  4. 存取權限:任何人
  5. 完成後複製網址

💬 評論區

老師們的交流與提問

載入評論中...